From 58bf4426d3dc3db51089bd6fd4af1e382040a5c6 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 21:25:16 +0100 Subject: feat(app): add the list rows and folder navigation model Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/list/ListModelTest.kt | 93 ++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt (limited to 'app/src/test/java') diff --git a/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt new file mode 100644 index 00000000..60e3020a --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.list + +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ListModelTest { + + private val root = Files.createTempDirectory("aps").toFile() + + @AfterTest + fun cleanUp() { + root.deleteRecursively() + } + + private fun file(path: String) = + File(root, path).apply { + parentFile!!.mkdirs() + writeText("x") + } + + private fun folder(path: String) = File(root, path).apply { mkdirs() } + + @Test + fun openingAChildFolderPushesIt() { + assertEquals(0 to listOf("work"), folderMoves("", "work")) + } + + @Test + fun breadcrumbJumpsOnlyPop() { + assertEquals(1 to emptyList(), folderMoves("work/servers", "work")) + assertEquals(2 to emptyList(), folderMoves("work/servers", "")) + } + + @Test + fun searchResultsRebuildTheStackFromTheSharedParent() { + assertEquals(1 to listOf("personal", "personal/mail"), folderMoves("work", "personal/mail")) + assertEquals( + 0 to listOf("work/servers", "work/servers/eu"), + folderMoves("work", "work/servers/eu"), + ) + } + + @Test + fun sameFolderMovesNothing() { + assertEquals(0 to emptyList(), folderMoves("work", "work")) + } + + @Test + fun browsingShowsNamesAndFolderCounts() { + val work = folder("work") + file("work/mail.gpg") + file("work/notes.txt") + folder("work/servers") + val site = file("site.gpg") + + val rows = listRows(listOf(work, site), root) + + assertEquals(listOf(ListRow(work.absolutePath, "work", null, 2)), rows.folders) + assertEquals(listOf(ListRow(site.absolutePath, "site", null, 0)), rows.entries) + } + + @Test + fun searchResultsShowWhereTheyAre() { + val servers = folder("work/servers") + val vpn = file("work/vpn.gpg") + + val rows = listRows(listOf(vpn, servers), root) + + assertEquals(listOf(ListRow(servers.absolutePath, "work/servers", null, 0)), rows.folders) + assertEquals(listOf(ListRow(vpn.absolutePath, "vpn", "work", 0)), rows.entries) + } + + @Test + fun keepsTheGivenOrderInsideEachGroup() { + val b = file("b.gpg") + val a = file("a.gpg") + val z = folder("z") + val y = folder("y") + + val rows = listRows(listOf(b, z, a, y), root) + + assertEquals(listOf(z.absolutePath, y.absolutePath), rows.folders.map { it.path }) + assertEquals(listOf(b.absolutePath, a.absolutePath), rows.entries.map { it.path }) + } +} -- cgit v1.2.3 From 0ba7321ce6b94a0046119e62a7ced8badb5baa5f Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 21:31:19 +0100 Subject: fix(app): harden store delete, move and rename - renameFolder stops when the move fails instead of recording and committing - delete runs its file work on the IO dispatcher - folder delete no longer follows directory symlinks (new test) - overwrite dialog is dismissed when its coroutine is cancelled Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/list/StoreActions.kt | 40 +++++++++++++++------- .../app/passwordstore/ui/list/StoreActionsTest.kt | 29 ++++++++++++++++ 2 files changed, 57 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt (limited to 'app/src/test/java') diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt index 6b31f652..06646461 100644 --- a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt +++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt @@ -22,6 +22,7 @@ import app.passwordstore.util.settings.PasswordSortOrder import app.passwordstore.util.settings.PreferenceKeys import com.google.android.material.dialog.MaterialAlertDialogBuilder import java.io.File +import java.nio.file.Files import kotlin.coroutines.resume import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext @@ -54,12 +55,24 @@ fun Context.recordUse(file: File) { } } +/** Deletes this file or folder, never following symlinks; true when everything is gone. */ +internal fun File.deleteWithoutFollowingLinks(): Boolean { + if (isDirectory && !Files.isSymbolicLink(toPath())) { + // Evaluate every child, no short-circuit, so one failure leaves the rest deleted. + val children = listFiles().orEmpty().map { it.deleteWithoutFollowingLinks() } + if (!children.all { it }) return false + } + return delete() +} + /** Deletes [files] (entries or whole folders), drops their autofill matches and commits. */ suspend fun BaseGitActivity.deleteItems(files: List) { val root = PasswordRepository.getRepositoryDirectory() - val deleted = files.flatMap { if (it.isDirectory) it.listFilesRecursively() else listOf(it) } - files.forEach { it.deleteRecursively() } - AutofillMatcher.updateMatches(applicationContext, delete = deleted) + withContext(dispatcherProvider.io()) { + val deleted = files.flatMap { if (it.isDirectory) it.listFilesRecursively() else listOf(it) } + files.forEach { it.deleteWithoutFollowingLinks() } + AutofillMatcher.updateMatches(applicationContext, delete = deleted) + } commitChange( getString( R.string.git_commit_remove_text, @@ -109,7 +122,7 @@ suspend fun BaseGitActivity.moveItems(files: List, target: File) { /** Renames the folder [from] to [to], keeps its recently-used time, then commits. */ suspend fun BaseGitActivity.renameFolder(from: File, to: File) { - moveFile(from, to) + if (!moveFile(from, to)) return val history = getSharedPreferences(HISTORY, Context.MODE_PRIVATE) val time = history.getString(from.absolutePath.base64()) if (time != null) @@ -120,7 +133,7 @@ suspend fun BaseGitActivity.renameFolder(from: File, to: File) { commitChange(getString(R.string.git_commit_move_text, from.name, to.name)) } -private suspend fun BaseGitActivity.moveFile(source: File, destination: File) { +private suspend fun BaseGitActivity.moveFile(source: File, destination: File): Boolean { val moved = withContext(dispatcherProvider.io()) { val map = @@ -143,17 +156,20 @@ private suspend fun BaseGitActivity.moveFile(source: File, destination: File) { .setPositiveButton(android.R.string.ok, null) .show() } + return moved } /** Shows [message] under [title] and suspends until answered; true when confirmed. */ // ponytail: View dialog, see moveFile. private suspend fun Context.confirm(title: String, message: String): Boolean = suspendCancellableCoroutine { cont -> - MaterialAlertDialogBuilder(this) - .setTitle(title) - .setMessage(message) - .setPositiveButton(R.string.dialog_ok) { _, _ -> cont.resume(true) } - .setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(false) } - .setOnCancelListener { cont.resume(false) } - .show() + val dialog = + MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.dialog_ok) { _, _ -> cont.resume(true) } + .setNegativeButton(R.string.dialog_cancel) { _, _ -> cont.resume(false) } + .setOnCancelListener { cont.resume(false) } + .show() + cont.invokeOnCancellation { dialog.dismiss() } } diff --git a/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt b/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt new file mode 100644 index 00000000..8a1ae1d9 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/list/StoreActionsTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ +package app.passwordstore.ui.list + +import java.io.File +import java.nio.file.Files +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StoreActionsTest { + + @Test + fun deleteDoesNotFollowSymlinks() { + val root = Files.createTempDirectory("aps").toFile() + val outsideDir = File(root, "outside").apply { mkdirs() } + val outsideFile = File(outsideDir, "secret.gpg").apply { writeText("x") } + val folder = File(root, "store/folder").apply { mkdirs() } + File(folder, "login.gpg").writeText("x") + Files.createSymbolicLink(File(folder, "link").toPath(), outsideDir.toPath()) + + assertTrue(folder.deleteWithoutFollowingLinks()) + + assertFalse(folder.exists()) + assertTrue(outsideFile.exists()) + } +} -- cgit v1.2.3 From 2329d0236ea39fd119b1199ae40d2b9f5a316506 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 2 Nov 2026 23:27:32 +0100 Subject: feat(app): add the entry view model and the OTP period Totp now carries its period, so the entry view can draw how much of the code's lifetime is left. EntryFields groups a decrypted entry the way the spec lays it out. Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/entry/EntryModel.kt | 35 +++++++++++++ .../app/passwordstore/ui/entry/EntryModelTest.kt | 57 ++++++++++++++++++++++ .../passwordstore/data/passfile/PasswordEntry.kt | 2 +- .../kotlin/app/passwordstore/data/passfile/Totp.kt | 8 ++- .../data/passfile/PasswordEntryTest.kt | 1 + 5 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt create mode 100644 app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt (limited to 'app/src/test/java') diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt new file mode 100644 index 00000000..8dbc9eb9 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryModel.kt @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.entry + +import androidx.compose.runtime.Immutable +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.data.passfile.Totp +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * What the entry view shows. [password] and [username] open the first group (the one-time code + * joins them from the entry's TOTP flow); [extras] are the remaining `key: value` lines in file + * order, plus lines that are not `key: value` under the parser's "Extra Content" key. Blank values + * are null, so their rows are not shown. + */ +@Immutable +data class EntryFields( + val password: String?, + val username: String?, + val extras: ImmutableList>, +) + +fun entryFields(entry: PasswordEntry): EntryFields = + EntryFields( + password = entry.password?.takeIf { it.isNotBlank() }, + username = entry.username?.takeIf { it.isNotBlank() }, + extras = entry.extraContent.toList().toImmutableList(), + ) + +/** Share of the code's lifetime left: 1 right after it was generated, toward 0 at expiry. */ +fun Totp.fractionLeft(): Float = (remainingTime / period).toFloat() diff --git a/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt b/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt new file mode 100644 index 00000000..290c51a9 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/entry/EntryModelTest.kt @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.entry + +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.data.passfile.Totp +import app.passwordstore.util.time.UserClock +import app.passwordstore.util.totp.UriTotpFinder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class EntryModelTest { + + private fun entry(content: String) = + PasswordEntry(UserClock(), UriTotpFinder(), content.encodeToByteArray()) + + @Test + fun firstGroupAndExtrasInFileOrder() { + val fields = + entryFields(entry("hunter2\nusername: alice\nurl: https://example.org\npin: 1234\n")) + assertEquals("hunter2", fields.password) + assertEquals("alice", fields.username) + assertEquals(listOf("url" to "https://example.org", "pin" to "1234"), fields.extras) + } + + @Test + fun blankPasswordAndMissingUsernameAreLeftOut() { + val fields = entryFields(entry("\nnote: none\n")) + assertNull(fields.password) + assertNull(fields.username) + assertEquals(listOf("note" to "none"), fields.extras) + } + + @Test + fun otpUriIsNotAnExtraField() { + val fields = + entryFields( + entry( + "s3cret\notpauth://totp/example.com:carol?secret=JBSWY3DPEHPK3PXP&issuer=example.com\n" + ) + ) + assertEquals("s3cret", fields.password) + assertTrue(fields.extras.isEmpty()) + } + + @Test + fun fractionLeftOfTheOtpPeriod() { + assertEquals(1f, Totp("123456", 30.seconds, 30.seconds).fractionLeft()) + assertEquals(0.5f, Totp("123456", 15.seconds, 30.seconds).fractionLeft()) + } +} diff --git a/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt b/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt index f0009dcf..1995342e 100644 --- a/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt +++ b/format/common/src/main/kotlin/app/passwordstore/data/passfile/PasswordEntry.kt @@ -224,7 +224,7 @@ constructor( digits, issuer, ) - .mapBoth({ code -> Ok(Totp(code, remainingTime)) }, ::Err) + .mapBoth({ code -> Ok(Totp(code, remainingTime, totpPeriod.seconds)) }, ::Err) } @AssistedFactory diff --git a/format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt b/format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt index 5e98ecd4..a26f03d9 100644 --- a/format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt +++ b/format/common/src/main/kotlin/app/passwordstore/data/passfile/Totp.kt @@ -7,5 +7,9 @@ package app.passwordstore.data.passfile import kotlin.time.Duration -/** Holder for a TOTP secret and the duration for which it is valid. */ -public data class Totp(public val value: String, public val remainingTime: Duration) +/** A TOTP code, how long it stays valid, and the full period it was generated for. */ +public data class Totp( + public val value: String, + public val remainingTime: Duration, + public val period: Duration, +) diff --git a/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt b/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt index 4efb140b..b7d41fd1 100644 --- a/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt +++ b/format/common/src/test/kotlin/app/passwordstore/data/passfile/PasswordEntryTest.kt @@ -134,6 +134,7 @@ class PasswordEntryTest { val otp = expectMostRecentItem() assertEquals("818800", otp.value) assertEquals(30.seconds, otp.remainingTime) + assertEquals(30.seconds, otp.period) cancelAndIgnoreRemainingEvents() } } -- cgit v1.2.3 From 8dad89ec9a37ab53a0de8983afafb0ef82d6d2d5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 08:36:30 +0100 Subject: feat(app): add the editor model and the picker's folder listing Pure functions for the Compose editor: the form and the pass file it writes, validation, the target file, the overwrite check, an atomic write, OTP URI building and the random generator's options. The folder picker lists subfolders with the list's hidden filter. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/editor/EditorModel.kt | 146 ++++++++++++++++++++ .../java/app/passwordstore/ui/list/ListModel.kt | 10 ++ .../app/passwordstore/ui/editor/EditorModelTest.kt | 152 +++++++++++++++++++++ .../app/passwordstore/ui/list/ListModelTest.kt | 12 ++ 4 files changed, 320 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt create mode 100644 app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt (limited to 'app/src/test/java') diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt new file mode 100644 index 00000000..cd57400d --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt @@ -0,0 +1,146 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.editor + +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import app.passwordstore.R +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.passgen.random.MaxIterationsExceededException +import app.passwordstore.passgen.random.NoCharactersIncludedException +import app.passwordstore.passgen.random.PasswordGenerator +import app.passwordstore.passgen.random.PasswordLengthTooShortException +import app.passwordstore.passgen.random.PasswordOption +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.mapError +import com.github.michaelbull.result.runCatching +import java.io.File +import java.io.IOException +import java.net.URLEncoder +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +/** + * What the editor edits. [dir] is the folder relative to the store root ("" is the root), [name] + * the file name without `.gpg`. Plaintext: held in memory only, never in saved state or a route. + */ +@Immutable +data class EditorForm( + val name: String = "", + val dir: String = "", + val password: String = "", + val username: String = "", + val extra: String = "", +) + +/** The form for [entry], stored as [name] in [dir], split as the old editor split it. */ +fun editorForm(entry: PasswordEntry, dir: String, name: String): EditorForm = + EditorForm( + name = name, + dir = dir, + password = entry.password.orEmpty(), + username = entry.username.orEmpty(), + extra = entry.extraContentWithoutUsername, + ) + +/** The pass file, as the old editor wrote it: password, `username: ...` when set, the extras. */ +fun passContent(form: EditorForm): String { + val username = if (form.username.isEmpty()) "" else "\nusername: ${form.username}" + return "${form.password}$username\n${form.extra}" +} + +/** Why [form] cannot be saved, as a string resource, or null when it can. */ +@StringRes +fun formError(form: EditorForm): Int? = + when { + form.name.isBlank() -> R.string.file_toast_text + '/' in form.name -> R.string.invalid_filename_text + form.password.isEmpty() && form.extra.isEmpty() -> R.string.empty_toast_text + else -> null + } + +/** Where [form] is saved in the store at [root]. */ +fun entryFile(root: File, form: EditorForm): File = + File(File(root, form.dir), "${form.name.trim()}.gpg") + +/** True when saving to [target] would replace a file other than the [original] being edited. */ +fun overwrites(target: File, original: File?): Boolean = + target.exists() && target.canonicalPath != original?.canonicalPath + +/** + * Writes [bytes] to [target], creating its folder, through a temporary file moved over it, so a + * failed write never truncates an existing entry. + */ +fun writeEntry(target: File, bytes: ByteArray) { + val dir = target.parentFile ?: throw IOException("No folder for $target") + dir.mkdirs() + val temp = File(dir, ".${target.name}.tmp") + try { + temp.writeBytes(bytes) + Files.move( + temp.toPath(), + target.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } finally { + temp.delete() + } +} + +/** [extra] with [line] added on a line of its own. */ +fun withLine(extra: String, line: String): String = + if (extra.isEmpty() || extra.endsWith('\n')) "$extra$line" else "$extra\n$line" + +/** + * An `otpauth://totp` URI, as the old manual OTP dialog built it, for a typed [secret] (spaces + * dropped: authenticators show it in groups) and an optional [issuer]; null for a blank secret. + */ +fun totpUri(secret: String, issuer: String): String? { + val key = secret.filterNot(Char::isWhitespace) + if (key.isEmpty()) return null + fun encode(s: String) = URLEncoder.encode(s, "UTF-8").replace("+", "%20") + val tail = if (issuer.isBlank()) "" else "&issuer=${encode(issuer.trim())}" + return "otpauth://totp?secret=${encode(key)}$tail" +} + +/** The random generator's options, as its checkboxes show them; defaults are the old dialog's. */ +@Immutable +data class RandomSpec( + val numerals: Boolean = true, + val symbols: Boolean = false, + val uppercase: Boolean = true, + val lowercase: Boolean = true, + val ambiguous: Boolean = true, + val pronounceable: Boolean = false, + val length: Int = 20, +) + +/** The generator's options for this spec, negated where the generator asks what to leave out. */ +fun RandomSpec.options(): List = + listOfNotNull( + PasswordOption.NoDigits.takeUnless { numerals }, + PasswordOption.AtLeastOneSymbol.takeIf { symbols }, + PasswordOption.NoUppercaseLetters.takeUnless { uppercase }, + PasswordOption.NoLowercaseLetters.takeUnless { lowercase }, + PasswordOption.NoAmbiguousCharacters.takeUnless { ambiguous }, + PasswordOption.FullyRandom.takeUnless { pronounceable }, + ) + +/** A random password for [spec], or the string resource saying why there is none. */ +fun randomPassword(spec: RandomSpec): Result = + runCatching { PasswordGenerator.generate(spec.options(), spec.length) } + .mapError { e -> + when (e) { + is MaxIterationsExceededException -> R.string.pwgen_max_iterations_exceeded + is NoCharactersIncludedException -> R.string.pwgen_no_chars_error + is PasswordLengthTooShortException -> R.string.pwgen_length_too_short_error + else -> R.string.pwgen_some_error_occurred + } + } + +/** The diceware generator's options; defaults are the old dialog's. */ +@Immutable data class DicewareSpec(val words: Int = 5, val separator: Char = '-') diff --git a/app/src/main/java/app/passwordstore/ui/list/ListModel.kt b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt index 9154baa5..96b7ab4b 100644 --- a/app/src/main/java/app/passwordstore/ui/list/ListModel.kt +++ b/app/src/main/java/app/passwordstore/ui/list/ListModel.kt @@ -48,6 +48,16 @@ fun listRows(files: List, dir: File): ListRows { private fun childCount(folder: File): Int = folder.listFiles { f -> f.isDirectory || f.extension == "gpg" }?.size ?: 0 +/** + * Folders directly inside [dir] that the folder picker offers, by name: never `.git`, other hidden + * folders only with "Show hidden contents" ([showHidden]), as the list filters them. + */ +fun subfolders(dir: File, showHidden: Boolean): List = + dir + .listFiles { f -> f.isDirectory && !f.name.startsWith(".git") && (showHidden || !f.isHidden) } + .orEmpty() + .sortedBy { it.name.lowercase() } + /** * How to get from the list at [from] to the list at [to] (both relative to the store root) while * the back stack holds one entry per folder level: how many entries to pop, then which paths to diff --git a/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt new file mode 100644 index 00000000..81174d05 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt @@ -0,0 +1,152 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.editor + +import app.passwordstore.R +import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.passgen.random.PasswordOption +import app.passwordstore.util.time.UserClock +import app.passwordstore.util.totp.UriTotpFinder +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EditorModelTest { + + private val root = Files.createTempDirectory("aps").toFile() + + @AfterTest + fun cleanUp() { + root.deleteRecursively() + } + + private fun entry(content: String) = + PasswordEntry(UserClock(), UriTotpFinder(), content.encodeToByteArray()) + + @Test + fun editingWithoutChangesKeepsThePassFile() { + val files = + listOf( + "hunter2\nusername: alice\nurl: https://example.org\n", + "s3cret\notpauth://totp/example.com:carol?secret=JBSWY3DPEHPK3PXP&issuer=example.com\n", + ) + for (content in files) assertEquals(content, passContent(editorForm(entry(content), "", "x"))) + } + + @Test + fun formFromAnEntrySplitsItsFields() { + val form = + editorForm(entry("hunter2\nusername: alice\nurl: https://example.org\n"), "work", "mail") + assertEquals(EditorForm("mail", "work", "hunter2", "alice", "url: https://example.org\n"), form) + } + + @Test + fun newEntryContent() { + assertEquals( + "pw\nusername: bob\nnote: x", + passContent(EditorForm(name = "n", password = "pw", username = "bob", extra = "note: x")), + ) + assertEquals("pw\n", passContent(EditorForm(name = "n", password = "pw"))) + } + + @Test + fun formErrors() { + assertEquals(R.string.file_toast_text, formError(EditorForm(name = " ", password = "pw"))) + assertEquals( + R.string.invalid_filename_text, + formError(EditorForm(name = "a/b", password = "pw")), + ) + assertEquals(R.string.empty_toast_text, formError(EditorForm(name = "a", username = "bob"))) + assertNull(formError(EditorForm(name = "a", extra = "note"))) + assertNull(formError(EditorForm(name = "a", password = "pw"))) + } + + @Test + fun entryFileTrimsTheNameAndUsesTheFolder() { + assertEquals( + File(root, "work/mail.gpg"), + entryFile(root, EditorForm(name = " mail ", dir = "work")), + ) + assertEquals(File(root, "mail.gpg"), entryFile(root, EditorForm(name = "mail"))) + } + + @Test + fun overwritesOnlyAnotherFile() { + val taken = File(root, "a.gpg").apply { writeText("x") } + assertFalse(overwrites(File(root, "work/b.gpg"), null)) + assertTrue(overwrites(taken, null)) + assertFalse(overwrites(taken, taken)) + assertTrue(overwrites(taken, File(root, "work/b.gpg"))) + } + + @Test + fun writeEntryCreatesTheFolderAndReplacesTheFile() { + val target = File(root, "new/dir/a.gpg") + writeEntry(target, byteArrayOf(1)) + writeEntry(target, byteArrayOf(2, 3)) + assertContentEquals(byteArrayOf(2, 3), target.readBytes()) + assertEquals(listOf("a.gpg"), target.parentFile!!.list()!!.toList()) + } + + @Test + fun linesGoOnTheirOwnLine() { + assertEquals("otpauth://x", withLine("", "otpauth://x")) + assertEquals("a\notpauth://x", withLine("a", "otpauth://x")) + assertEquals("a\notpauth://x", withLine("a\n", "otpauth://x")) + } + + @Test + fun manualTotpUriIsReadBackByTheParser() { + val uri = totpUri(" JBSW Y3DP EHPK 3PXP ", "Example Co")!! + assertEquals("JBSWY3DPEHPK3PXP", UriTotpFinder().findSecret(uri)) + assertEquals("Example Co", UriTotpFinder().findIssuer(uri)) + assertTrue(entry("PLACEHOLDER\n$uri").hasTotp()) + assertEquals("otpauth://totp?secret=JBSWY3DPEHPK3PXP", totpUri("JBSWY3DPEHPK3PXP", " ")) + assertNull(totpUri(" ", "Example Co")) + } + + @Test + fun randomOptionsFollowTheCheckboxes() { + assertEquals(listOf(PasswordOption.FullyRandom), RandomSpec().options()) + assertEquals( + listOf( + PasswordOption.NoDigits, + PasswordOption.AtLeastOneSymbol, + PasswordOption.NoUppercaseLetters, + PasswordOption.NoLowercaseLetters, + PasswordOption.NoAmbiguousCharacters, + ), + RandomSpec( + numerals = false, + symbols = true, + uppercase = false, + lowercase = false, + ambiguous = false, + pronounceable = true, + ) + .options(), + ) + } + + @Test + fun randomPasswordOrWhyNot() { + assertEquals(20, randomPassword(RandomSpec()).value.length) + assertEquals( + R.string.pwgen_no_chars_error, + randomPassword(RandomSpec(numerals = false, uppercase = false, lowercase = false)).error, + ) + assertEquals( + R.string.pwgen_length_too_short_error, + randomPassword(RandomSpec(length = 2)).error, + ) + } +} diff --git a/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt index 60e3020a..cf12deef 100644 --- a/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt +++ b/app/src/test/java/app/passwordstore/ui/list/ListModelTest.kt @@ -90,4 +90,16 @@ class ListModelTest { assertEquals(listOf(z.absolutePath, y.absolutePath), rows.folders.map { it.path }) assertEquals(listOf(b.absolutePath, a.absolutePath), rows.entries.map { it.path }) } + + @Test + fun pickerOffersFoldersByName() { + folder("b") + folder("A") + folder(".hidden") + folder(".git") + file("c.gpg") + assertEquals(listOf("A", "b"), subfolders(root, showHidden = false).map { it.name }) + assertEquals(listOf(".hidden", "A", "b"), subfolders(root, showHidden = true).map { it.name }) + assertEquals(emptyList(), subfolders(File(root, "missing"), showHidden = false)) + } } -- cgit v1.2.3 From 84d89854ce02a5833e88bfd9c150b8e90b51b4a5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 08:43:49 +0100 Subject: fix(app): harden the editor save path and the picker - Run the save's write, original delete, autofill and history updates under NonCancellable so a recreated activity cannot leave a half done rename. - fsync the temporary file before the atomic move. - Move dialog: refuse targets outside the store (symlinks) and moving a folder into itself or a descendant, with a toast. - Folder picker: do not navigate into a new folder whose mkdirs failed; show a toast (new string, en and it). - Normalize entryFile so "./" and "a/../b" give the same keys as the list; covered in EditorModelTest. Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/editor/EditorModel.kt | 8 ++++++-- .../app/passwordstore/ui/editor/EditorViewModel.kt | 5 ++++- .../java/app/passwordstore/ui/list/FolderPicker.kt | 8 ++++++-- .../app/passwordstore/ui/list/ListDestination.kt | 20 +++++++++++++++++--- app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../app/passwordstore/ui/editor/EditorModelTest.kt | 4 ++++ 7 files changed, 39 insertions(+), 8 deletions(-) (limited to 'app/src/test/java') diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt index cd57400d..d7437df3 100644 --- a/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorModel.kt @@ -18,6 +18,7 @@ import com.github.michaelbull.result.Result import com.github.michaelbull.result.mapError import com.github.michaelbull.result.runCatching import java.io.File +import java.io.FileOutputStream import java.io.IOException import java.net.URLEncoder import java.nio.file.Files @@ -64,7 +65,7 @@ fun formError(form: EditorForm): Int? = /** Where [form] is saved in the store at [root]. */ fun entryFile(root: File, form: EditorForm): File = - File(File(root, form.dir), "${form.name.trim()}.gpg") + File(File(root, form.dir), "${form.name.trim()}.gpg").toPath().normalize().toFile() /** True when saving to [target] would replace a file other than the [original] being edited. */ fun overwrites(target: File, original: File?): Boolean = @@ -79,7 +80,10 @@ fun writeEntry(target: File, bytes: ByteArray) { dir.mkdirs() val temp = File(dir, ".${target.name}.tmp") try { - temp.writeBytes(bytes) + FileOutputStream(temp).use { + it.write(bytes) + it.fd.sync() + } Files.move( temp.toPath(), target.toPath(), diff --git a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt index b67b435d..0c8b4676 100644 --- a/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/editor/EditorViewModel.kt @@ -42,6 +42,7 @@ import java.io.ByteArrayOutputStream import java.io.File import java.io.IOException import javax.inject.Inject +import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.withContext import logcat.LogPriority.ERROR import logcat.asLog @@ -216,7 +217,9 @@ constructor( encrypted.error.message.orEmpty(), ) } - return withContext(dispatcherProvider.io()) { + // NonCancellable: a recreated activity must not stop between writing the target and deleting + // the original, which would leave a rename that cannot be retried ("duplicate"). + return withContext(NonCancellable + dispatcherProvider.io()) { try { writeEntry(target, out.toByteArray()) } catch (e: IOException) { diff --git a/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt index 5b610ff7..bd5b66f3 100644 --- a/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt +++ b/app/src/main/java/app/passwordstore/ui/list/FolderPicker.kt @@ -5,6 +5,7 @@ package app.passwordstore.ui.list +import android.widget.Toast import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -79,9 +80,12 @@ fun FolderPicker( validate = { folderNameError(it, File(dir, it)) }, onConfirm = { name -> val folder = File(dir, name).canonicalFile - folder.mkdirs() naming = false - path = folder.relativeTo(root.canonicalFile).path + if (folder.mkdirs() || folder.isDirectory) { + path = folder.relativeTo(root.canonicalFile).path + } else { + Toast.makeText(context, R.string.folder_creation_err_failed, Toast.LENGTH_LONG).show() + } }, onDismiss = { naming = false }, ) diff --git a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt index 147e77b7..5c248dcb 100644 --- a/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/list/ListDestination.kt @@ -5,6 +5,7 @@ package app.passwordstore.ui.list +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -36,6 +37,7 @@ import app.passwordstore.ui.compose.theme.SpacingMedium import app.passwordstore.ui.git.base.BaseGitActivity.GitOp import app.passwordstore.ui.git.config.GitServerConfigActivity import app.passwordstore.ui.main.MainActivity +import app.passwordstore.util.extensions.isInsideRepository import app.passwordstore.util.extensions.sharedPrefs import app.passwordstore.util.settings.AuthMode import app.passwordstore.util.settings.PreferenceKeys @@ -237,12 +239,24 @@ fun ListDestination( start = path, onPick = { target -> // One move per pick: a double tap on Select must not move twice. - if (dialog == ListDialog.Move) { - val files = selected.map(::File) + val destination = File(root, target) + val files = selected.map(::File) + val canonical = destination.canonicalFile + if ( + !destination.isInsideRepository() || + files.any { it.isDirectory && canonical.startsWith(it.canonicalFile) } + ) { + Toast.makeText( + activity, + R.string.message_error_destination_outside_repo, + Toast.LENGTH_LONG, + ) + .show() + } else if (dialog == ListDialog.Move) { dialog = ListDialog.None selected = emptyList() activity.lifecycleScope.launch { - activity.moveItems(files, File(root, target)) + activity.moveItems(files, destination) refresh() } } diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 17e149af..100b0b7c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -361,5 +361,6 @@ Scegli una cartella + Impossibile creare la cartella Nuova cartella diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5370751e..a6d9dbba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -395,5 +395,6 @@ Choose a folder + Could not create the folder New folder diff --git a/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt index 81174d05..160434c9 100644 --- a/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt +++ b/app/src/test/java/app/passwordstore/ui/editor/EditorModelTest.kt @@ -77,6 +77,10 @@ class EditorModelTest { entryFile(root, EditorForm(name = " mail ", dir = "work")), ) assertEquals(File(root, "mail.gpg"), entryFile(root, EditorForm(name = "mail"))) + assertEquals( + File(root, "b/mail.gpg"), + entryFile(root, EditorForm(name = "mail", dir = "./a/../b")), + ) } @Test -- cgit v1.2.3 From a3c105af9adb2b45ad52fe47218cc8827741dca9 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 09:48:36 +0100 Subject: fix(git): get sshj's Ed25519 keys from Bouncy Castle Recent Conscrypt (Android 14 mainline updates) registers a KeyFactory for Ed25519. With sshj leaving provider selection to the JCA, its Ed25519KeyFactory now gets Conscrypt's key class, whose algorithm name sshj does not map to ssh-ed25519: KeyType.fromKey returns UNKNOWN and every Ed25519 host key fails with "Don't know how to encode key", so clone and sync over SSH break. 0.1.4 is affected since the sshj 0.41.2 update. Register a provider at position 1 that serves only KeyFactory.Ed25519 from Bouncy Castle (kept by the existing R8 rule). Everything else, including signatures with Android Keystore keys, still goes through normal JCA provider selection. Verified on an Android 14 tablet: clone over SSH failed before and works with the fix. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit ad3c63f665e6264ed1db42d6ae2dfc0947e33393) --- AGENTS.md | 4 ++++ .../java/app/passwordstore/util/git/sshj/SshjConfig.kt | 18 ++++++++++++++++++ .../app/passwordstore/util/git/sshj/SshjConfigTest.kt | 14 ++++++++++++++ 3 files changed, 36 insertions(+) (limited to 'app/src/test/java') diff --git a/AGENTS.md b/AGENTS.md index 91c0857b..f2e32456 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,6 +122,10 @@ passed. - `LaunchActivity` is exported and forwards `FILE_PATH` to `MainActivity` (not exported) only after `isFileInRepo` checks it canonically resolves inside the store; `MainActivity` opens it as `EntryRoute`, whose destination checks it again before decrypting. +- sshj gets Ed25519 keys from Bouncy Castle: `SshjConfig.kt` pins `KeyFactory.Ed25519` to it at + provider position 1. Conscrypt from Android 14 mainline updates serves that factory with keys + sshj maps to `KeyType.UNKNOWN` ("Don't know how to encode key"), breaking every Ed25519 host + and user key. The emulator image does not show it; a 2026-03 Android 14 tablet does. - AGP 9 applies packaging excludes (`AndroidCommon`, e.g. `**/*.txt`) to unit-test java resources as well. A module whose tests load such a file must remove the exclude locally (see `passgen/diceware/build.gradle.kts`). diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt index b43d0007..3a3538ac 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshjConfig.kt @@ -12,6 +12,7 @@ import com.hierynomus.sshj.transport.cipher.GcmCiphers import com.hierynomus.sshj.transport.kex.ExtInfoClientFactory import com.hierynomus.sshj.transport.mac.Macs import com.hierynomus.sshj.userauth.keyprovider.OpenSSHKeyV1KeyFile +import java.security.Provider import java.security.Security import logcat.LogPriority.VERBOSE import logcat.logcat @@ -32,6 +33,18 @@ import net.schmizz.sshj.userauth.keyprovider.PuTTYKeyFile import org.bouncycastle.jce.provider.BouncyCastleProvider import org.slf4j.Logger +private object Ed25519KeyFactoryProvider : + Provider("HardenedPassEd25519", 1.0, "Ed25519 KeyFactory from Bouncy Castle") { + private fun readResolve(): Any = Ed25519KeyFactoryProvider + + init { + put( + "KeyFactory.Ed25519", + "org.bouncycastle.jcajce.provider.asymmetric.edec.KeyFactorySpi\$Ed25519", + ) + } +} + fun setUpBouncyCastleForSshj() { // Replace the Android BC provider with the Java BouncyCastle provider since the former does // not include all the required algorithms. @@ -48,6 +61,11 @@ fun setUpBouncyCastleForSshj() { runCatching { Class.forName("sun.security.jca.Providers") } Security.insertProviderAt(BouncyCastleProvider(), bcIndex + 1) } + // Recent Conscrypt (Android 14 mainline updates) serves KeyFactory "Ed25519" with keys whose + // algorithm name sshj does not map to ssh-ed25519 (KeyType.UNKNOWN, "Don't know how to encode + // key"), which breaks every Ed25519 host and user key. Pin only that factory to Bouncy Castle, + // ahead of every other provider; signatures still pick a provider per key. + Security.insertProviderAt(Ed25519KeyFactoryProvider, 1) logcat("setUpBouncyCastleForSshj", priority = VERBOSE) { "JCE providers: ${Security.getProviders().joinToString { "${it.name} (${it.version})" }}" } diff --git a/app/src/test/java/app/passwordstore/util/git/sshj/SshjConfigTest.kt b/app/src/test/java/app/passwordstore/util/git/sshj/SshjConfigTest.kt index af6eee47..e4bc3eec 100644 --- a/app/src/test/java/app/passwordstore/util/git/sshj/SshjConfigTest.kt +++ b/app/src/test/java/app/passwordstore/util/git/sshj/SshjConfigTest.kt @@ -6,7 +6,11 @@ package app.passwordstore.util.git.sshj import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertNull +import kotlin.test.assertTrue +import net.schmizz.sshj.common.Ed25519KeyFactory +import net.schmizz.sshj.common.KeyType import net.schmizz.sshj.common.SecurityUtils class SshjConfigTest { @@ -19,4 +23,14 @@ class SshjConfigTest { setUpBouncyCastleForSshj() assertNull(SecurityUtils.getSecurityProvider()) } + + // Conscrypt's Ed25519 keys map to KeyType.UNKNOWN in sshj, so Ed25519 keys must come from + // Bouncy Castle whatever provider the platform puts first. + @Test + fun ed25519KeysComeFromBouncyCastle() { + setUpBouncyCastleForSshj() + val key = Ed25519KeyFactory.getPublicKey(ByteArray(32) { 1 }) + assertTrue(key.javaClass.name.startsWith("org.bouncycastle."), key.javaClass.name) + assertEquals(KeyType.ED25519, KeyType.fromKey(key)) + } } -- cgit v1.2.3 From 6a614fe7dc1cbbec5783828d58b246a362edea07 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 13:44:42 +0100 Subject: feat(app): add the setup model and stricter key checks URL checks and fixes, authentication modes per URL, the clone target, the store's recipients, a size- and symlink-checked .gpg-id read and the .gpg-id text for a new store (uppercase long key IDs), as pure functions for the Compose setup screens. The SSH key import now also requires a format sshj recognizes (an OpenPGP private key block matched the old marker check and was imported as the SSH key) and no longer crashes on an empty size cursor. KeyUtils tells secret keys apart and no longer throws on a key without user ID. Co-Authored-By: Claude Opus 5.5 --- .../java/app/passwordstore/ui/setup/SetupModel.kt | 102 +++++++++++++++++ .../java/app/passwordstore/util/git/sshj/SshKey.kt | 35 ++++-- .../app/passwordstore/ui/setup/SetupModelTest.kt | 121 +++++++++++++++++++++ .../passwordstore/util/git/sshj/SshKeyFileTest.kt | 33 ++++++ .../kotlin/app/passwordstore/crypto/KeyUtils.kt | 7 +- .../app/passwordstore/crypto/KeyUtilsTest.kt | 19 ++++ 6 files changed, 306 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt create mode 100644 app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt create mode 100644 app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt (limited to 'app/src/test/java') diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt new file mode 100644 index 00000000..8c7b20a1 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt @@ -0,0 +1,102 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.util.settings.AuthMode +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.LinkOption +import java.util.Locale + +/** Why a repository URL is questioned before it is saved, as the old server screen checked it. */ +enum class UrlProblem { + /** An HTTPS URL with a custom port, which the old screen did not support. */ + HttpsPort, + /** An scp-style SSH URL with a port: without `ssh://` the port is read as part of the path. */ + SshScheme, + /** The unencrypted `git://` protocol. */ + GitScheme, +} + +private val PORT = ":[0-9]{1,5}/".toRegex() + +private fun isHttp(url: String) = url.startsWith("http://") || url.startsWith("https://") + +/** What is wrong with [url], or null. `git://` is checked first: a port does not make it SSH. */ +fun urlProblem(url: String): UrlProblem? = + when { + url.startsWith("git://") -> UrlProblem.GitScheme + !url.contains(PORT) -> null + url.startsWith("https://") -> UrlProblem.HttpsPort + url.startsWith("http://") || url.startsWith("ssh://") -> null + else -> UrlProblem.SshScheme + } + +/** The old screen's fix for [problem] in [url], or null when there is none (`git://`). */ +fun fixUrl(url: String, problem: UrlProblem): String? = + when (problem) { + UrlProblem.HttpsPort -> url.replace(PORT, "/") + UrlProblem.SshScheme -> "ssh://$url" + UrlProblem.GitScheme -> null + } + +/** The authentication modes offered for [url], the default first. */ +fun authModes(url: String): List = + if (isHttp(url)) listOf(AuthMode.None, AuthMode.Password) + else listOf(AuthMode.SshKey, AuthMode.Password) + +/** [current] when [url] still allows it, the default for [url] otherwise. */ +fun fitAuthMode(url: String, current: AuthMode): AuthMode = + authModes(url).let { modes -> if (current in modes) current else modes.first() } + +/** What a clone into the store folder finds there. */ +enum class CloneTarget { + /** Missing or empty. */ + Empty, + /** Only a `.git` folder, from a store created and abandoned: deleted without asking. */ + OnlyGit, + /** Anything else: deleted after a confirmation. */ + Occupied, +} + +fun cloneTarget(dir: File): CloneTarget { + val files = dir.listFiles().orEmpty() + return when { + files.isEmpty() -> CloneTarget.Empty + files.size == 1 && files[0].name == ".git" -> CloneTarget.OnlyGit + else -> CloneTarget.Occupied + } +} + +/** The recipients named by a `.gpg-id` file's [text]; blank and unparsable lines are skipped. */ +fun storeRecipients(text: String): List = + text.lines().map(String::trim).filter(String::isNotEmpty).mapNotNull(PGPIdentifier::fromString) + +/** + * A `.gpg-id` naming [keys]: each 16-hex long key ID in uppercase on a line of its own, the format + * of a desktop store made with `pass init `. + */ +fun gpgIdText(keys: List): String = + keys.joinToString("") { "${it.toString().uppercase(Locale.ROOT)}\n" } + +private const val MAX_GPG_ID_BYTES = 64 * 1024 + +/** + * The text of [dir]'s `.gpg-id`, or null when it is missing, not a regular file, a symlink, larger + * than 64 KiB or unreadable. Blocking: call it off the main thread. + */ +fun readGpgId(dir: File): String? { + val file = File(dir, ".gpg-id") + val path = file.toPath() + return try { + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) null + else if (Files.size(path) > MAX_GPG_ID_BYTES) null else file.readText() + } catch (_: IOException) { + null + } +} diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt index 109e36c9..8d5519a8 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt @@ -46,7 +46,9 @@ import net.schmizz.sshj.SSHClient import net.schmizz.sshj.common.Buffer import net.schmizz.sshj.common.Ed25519KeyFactory import net.schmizz.sshj.common.KeyType +import net.schmizz.sshj.userauth.keyprovider.KeyFormat import net.schmizz.sshj.userauth.keyprovider.KeyProvider +import net.schmizz.sshj.userauth.keyprovider.KeyProviderUtil import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters private const val PROVIDER_ANDROID_KEY_STORE = "AndroidKeyStore" @@ -82,6 +84,25 @@ internal fun ed25519KeyPair(seed: ByteArray) = Ed25519KeyFactory.getPrivateKey(seed), ) +/** + * True when [lines] look like an SSH private key sshj can load: private key markers on the first + * and last line, in a format sshj recognizes. An OpenPGP private key block has the same markers and + * is refused by the format check. + */ +internal fun isSshPrivateKey(lines: List): Boolean { + if ( + lines.size < 2 || + !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) || + !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) + ) + return false + return try { + KeyProviderUtil.detectKeyFileFormat(lines.joinToString("\n"), false) != KeyFormat.Unknown + } catch (_: IOException) { + false + } +} + object SshKey { val sshPublicKey @@ -198,9 +219,8 @@ object SshKey { val fileSize = context.contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor -> - // Cursor returns only a single row. - cursor.moveToFirst() - cursor.getInt(0) + // A single row; none when the provider cannot tell: the IOException below. + if (cursor.moveToFirst()) cursor.getInt(0) else null } ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) // We assume that an SSH key's ideal size is > 0 bytes && < 100 kilobytes. @@ -214,13 +234,8 @@ object SshKey { ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) val lines = sshKeyInputStream.use { `is` -> `is`.bufferedReader().readLines() } - // The file must have more than 2 lines, and the first and last line must have private key - // markers. - if ( - lines.size < 2 || - !Regex("BEGIN .* PRIVATE KEY").containsMatchIn(lines.first()) || - !Regex("END .* PRIVATE KEY").containsMatchIn(lines.last()) - ) + // Private key markers and a format sshj can read; the current key stays when this fails. + if (!isSshPrivateKey(lines)) throw IllegalArgumentException( context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) ) diff --git a/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt new file mode 100644 index 00000000..8d8243eb --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.setup + +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.util.settings.AuthMode +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull + +class SetupModelTest { + + private val root = Files.createTempDirectory("aps").toFile() + + @AfterTest + fun cleanUp() { + root.deleteRecursively() + } + + @Test + fun urlProblems() { + assertEquals(UrlProblem.HttpsPort, urlProblem("https://example.org:8443/store.git")) + assertEquals(UrlProblem.SshScheme, urlProblem("git@example.org:2222/srv/store.git")) + assertEquals(UrlProblem.GitScheme, urlProblem("git://example.org/store.git")) + assertEquals(UrlProblem.GitScheme, urlProblem("git://example.org:9418/store.git")) + assertNull(urlProblem("ssh://git@example.org:2222/srv/store.git")) + assertNull(urlProblem("git@example.org:srv/store.git")) + assertNull(urlProblem("https://example.org/store.git")) + assertNull(urlProblem("http://example.org:8080/store.git")) + } + + @Test + fun urlFixes() { + assertEquals( + "https://example.org/store.git", + fixUrl("https://example.org:8443/store.git", UrlProblem.HttpsPort), + ) + assertEquals( + "ssh://git@example.org:2222/srv/store.git", + fixUrl("git@example.org:2222/srv/store.git", UrlProblem.SshScheme), + ) + assertNull(fixUrl("git://example.org/store.git", UrlProblem.GitScheme)) + } + + @Test + fun authModesFollowTheUrl() { + val ssh = listOf(AuthMode.SshKey, AuthMode.Password) + val http = listOf(AuthMode.None, AuthMode.Password) + assertEquals(ssh, authModes("ssh://git@example.org/store.git")) + assertEquals(ssh, authModes("git@example.org:store.git")) + assertEquals(ssh, authModes("")) + assertEquals(http, authModes("https://example.org/store.git")) + assertEquals(http, authModes("http://example.org/store.git")) + assertEquals(AuthMode.None, fitAuthMode("https://example.org/x", AuthMode.SshKey)) + assertEquals(AuthMode.Password, fitAuthMode("https://example.org/x", AuthMode.Password)) + assertEquals(AuthMode.SshKey, fitAuthMode("git@example.org:x", AuthMode.None)) + assertEquals(AuthMode.Password, fitAuthMode("git@example.org:x", AuthMode.Password)) + } + + @Test + fun cloneTargets() { + val store = File(root, "store") + assertEquals(CloneTarget.Empty, cloneTarget(store)) + store.mkdirs() + assertEquals(CloneTarget.Empty, cloneTarget(store)) + File(store, ".git").mkdirs() + assertEquals(CloneTarget.OnlyGit, cloneTarget(store)) + File(store, "a.gpg").writeText("x") + assertEquals(CloneTarget.Occupied, cloneTarget(store)) + File(store, ".git").deleteRecursively() + assertEquals(CloneTarget.Occupied, cloneTarget(store)) + } + + @Test + fun storeRecipientsSkipBlankAndInvalidLines() { + val ids = + storeRecipients( + "4882E8257A3700D43119B8D3F8993A789CDFB014\n\n Alice \nnot an id\n" + ) + assertEquals(listOf("f8993a789cdfb014", "alice@example.org"), ids.map(PGPIdentifier::toString)) + assertEquals(emptyList(), storeRecipients("")) + } + + @Test + fun gpgIdTextIsUppercaseKeyIdsOnePerLine() { + val ids = + listOf("f8993a789cdfb014", "0x79E8208280490C77").map { + assertIs(PGPIdentifier.fromString(it)) + } + val text = gpgIdText(ids) + assertEquals("F8993A789CDFB014\n79E8208280490C77\n", text) + // pass and this app read it back as the same key IDs. + assertEquals(ids, storeRecipients(text)) + } + + @Test + fun gpgIdIsReadOnlyFromASmallRegularFile() { + assertNull(readGpgId(root)) + File(root, ".gpg-id").writeText("F8993A789CDFB014\n") + assertEquals("F8993A789CDFB014\n", readGpgId(root)) + + val linked = File(root, "linked").apply { mkdirs() } + val outside = File(root, "outside").apply { writeText("F8993A789CDFB014\n") } + Files.createSymbolicLink(File(linked, ".gpg-id").toPath(), outside.toPath()) + assertNull(readGpgId(linked)) + + val big = File(root, "big").apply { mkdirs() } + File(big, ".gpg-id").writeText("A".repeat(64 * 1024 + 1)) + assertNull(readGpgId(big)) + + val folder = File(root, "folder").apply { File(this, ".gpg-id").mkdirs() } + assertNull(readGpgId(folder)) + } +} diff --git a/app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt b/app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt new file mode 100644 index 00000000..1a9914fb --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.git.sshj + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SshKeyFileTest { + + private fun pem(type: String) = + listOf("-----BEGIN $type-----", "b3BlbnNzaC1rZXktdjEAAAAABG5vbmU", "-----END $type-----") + + @Test + fun acceptsSshPrivateKeys() { + assertTrue(isSshPrivateKey(pem("OPENSSH PRIVATE KEY"))) + assertTrue(isSshPrivateKey(pem("RSA PRIVATE KEY"))) + assertTrue(isSshPrivateKey(pem("EC PRIVATE KEY"))) + } + + @Test + fun refusesOtherFiles() { + // The step 4 finding: an OpenPGP key block has the same markers and was accepted. + assertFalse(isSshPrivateKey(pem("PGP PRIVATE KEY BLOCK"))) + assertFalse(isSshPrivateKey(pem("PGP PUBLIC KEY BLOCK"))) + assertFalse(isSshPrivateKey(listOf("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIA test@example.org"))) + assertFalse(isSshPrivateKey(emptyList())) + assertFalse(isSshPrivateKey(pem("OPENSSH PRIVATE KEY").dropLast(1))) + } +} diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt index f85fe92c..311e9049 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt @@ -10,6 +10,7 @@ import app.passwordstore.crypto.PGPIdentifier.UserId import com.github.michaelbull.result.get import com.github.michaelbull.result.runCatching import org.bouncycastle.openpgp.PGPKeyRing +import org.bouncycastle.openpgp.PGPSecretKeyRing import org.pgpainless.PGPainless import org.pgpainless.key.parsing.KeyRingReader @@ -35,9 +36,13 @@ public object KeyUtils { */ public fun tryGetEmail(key: PGPKey): UserId? { val keyRing = tryParseKeyring(key) ?: return null - return UserId(keyRing.publicKey.userIDs.next()) + // A key may carry no user ID at all: next() threw NoSuchElementException there. + return keyRing.publicKey.userIDs.asSequence().firstOrNull()?.let(::UserId) } + /** True when [key] parses as a secret key ring, the only kind that can decrypt. */ + public fun isSecretKey(key: PGPKey): Boolean = tryParseKeyring(key) is PGPSecretKeyRing + /** * Tests if the given [key] can be used for encryption, which is a bare minimum necessity for the * app. diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt index e6e3d7d1..3281c334 100644 --- a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt +++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt @@ -6,14 +6,18 @@ package app.passwordstore.crypto import app.passwordstore.crypto.KeyUtils.isKeyUsable +import app.passwordstore.crypto.KeyUtils.isSecretKey +import app.passwordstore.crypto.KeyUtils.tryGetEmail import app.passwordstore.crypto.KeyUtils.tryGetId import app.passwordstore.crypto.KeyUtils.tryParseKeyring import app.passwordstore.crypto.TestUtils.AllKeys import app.passwordstore.crypto.TestUtils.getArmoredSecretKeyWithMultipleIdentities import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertNotNull +import kotlin.test.assertTrue import org.bouncycastle.openpgp.PGPSecretKeyRing class KeyUtilsTest { @@ -37,4 +41,19 @@ class KeyUtilsTest { assertEquals(isUsable, isKeyUsable(key), "${allKeys.name} failed expectation:") } } + + @Test + fun secretKeysAreTold() { + assertTrue(isSecretKey(PGPKey(AllKeys.ARMORED_SEC.keyMaterial))) + assertTrue(isSecretKey(PGPKey(AllKeys.GNUPG_OCB_SEC.keyMaterial))) + assertFalse(isSecretKey(PGPKey(AllKeys.ARMORED_PUB.keyMaterial))) + assertFalse(isSecretKey(PGPKey("not a key".encodeToByteArray()))) + } + + @Test + fun firstUserIdIsReturned() { + val key = PGPKey(getArmoredSecretKeyWithMultipleIdentities()) + assertNotNull(tryGetEmail(key)) + assertEquals(null, tryGetEmail(PGPKey("not a key".encodeToByteArray()))) + } } -- cgit v1.2.3 From fcbd5999f28f287507844b55f2bee82e714620c8 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 13:57:05 +0100 Subject: fix(app): harden the setup key imports - PGP key import reads at most 1 MiB (bounded read, buffer zeroed) and refuses larger files with a new message. - SshKeyViewModel catches any Exception from import and generation (CancellationException rethrown) instead of a short list, so an unexpected provider or sshj error no longer crashes the app. - Pending PGP key bytes are zeroed in a finally block, also on cancellation during addKey after a replace, and an earlier pending pick is zeroed before a new one takes its place. - PGPKeyManager never replaces a stored secret key with a public-only key of the same ID, with or without replace (KeyDowngradeException); the setup ViewModel shows a localized message for it. Unit test added. - storeRecipients strips '#' comments and a trailing '!' as pass reads .gpg-id; tests added. - GitServerViewModel.clearStore returns the failure when the store folder cannot be emptied, so the clone does not start; the plan's Task 3 call site reports it. - readGpgId opens the file with NOFOLLOW_LINKS and reads at most 64 KiB + 1 bytes, refusing more. - SshKey.import writes the new private key to a temp file and moves it over atomically before dropping the old key's Keystore entry, public key and passphrase, so an I/O error keeps the old key. - KeyUtils.isSecretKey notes (ponytail) that GnuPG stub and smartcard keyrings count as secret. Co-Authored-By: Claude Opus 5.5 --- .../passwordstore/ui/setup/GitServerViewModel.kt | 11 ++-- .../app/passwordstore/ui/setup/PgpKeysViewModel.kt | 58 +++++++++++++++------- .../java/app/passwordstore/ui/setup/SetupModel.kt | 45 ++++++++++++++--- .../app/passwordstore/ui/setup/SshKeyViewModel.kt | 22 ++++---- .../java/app/passwordstore/util/git/sshj/SshKey.kt | 32 +++++++++--- app/src/main/res/values-it/strings.xml | 3 ++ app/src/main/res/values/strings.xml | 3 ++ .../app/passwordstore/ui/setup/SetupModelTest.kt | 19 +++++++ .../passwordstore/crypto/errors/CryptoException.kt | 4 ++ .../kotlin/app/passwordstore/crypto/KeyUtils.kt | 2 + .../app/passwordstore/crypto/PGPKeyManager.kt | 4 ++ .../app/passwordstore/crypto/PGPKeyManagerTest.kt | 11 ++++ docs/superpowers/plans/2026-11-03-compose-setup.md | 8 ++- 13 files changed, 172 insertions(+), 50 deletions(-) (limited to 'app/src/test/java') diff --git a/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt index f2b8ca30..d549624e 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/GitServerViewModel.kt @@ -83,13 +83,16 @@ constructor( /** * Empties the store folder at [root] for a clone, unless it is empty already. Never follows a * symlink: `deleteRecursively()` would, and a `store/link -> ../keys` link would take the PGP - * keys with it. + * keys with it. Returns null, or why it failed as a string resource: then the clone must not + * start. */ - suspend fun clearStore(root: File) { - if (withContext(dispatcherProvider.io()) { cloneTarget(root) } == CloneTarget.Empty) return + suspend fun clearStore(root: File): Int? { + if (withContext(dispatcherProvider.io()) { cloneTarget(root) } == CloneTarget.Empty) return null progress = R.string.delete_directory_progress_text - withContext(dispatcherProvider.io()) { root.deleteWithoutFollowingLinks() } + if (!withContext(dispatcherProvider.io()) { root.deleteWithoutFollowingLinks() }) + return R.string.git_clone_delete_failed progress = R.string.git_clone_progress + return null } /** Set while [finishClone]'s key check runs: the clone's [progress] stays up until it is done. */ diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt index e6000206..ed7fcf37 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt @@ -13,11 +13,13 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import app.passwordstore.R import app.passwordstore.crypto.KeyUtils import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.crypto.PGPKey import app.passwordstore.crypto.PGPKeyManager import app.passwordstore.crypto.errors.KeyAlreadyExistsException +import app.passwordstore.crypto.errors.KeyDowngradeException import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.util.coroutines.DispatcherProvider import com.github.michaelbull.result.onSuccess @@ -126,18 +128,23 @@ constructor( } } - /** Reads the key file at [uri] and imports it. */ + /** Reads the key file at [uri] (at most [MAX_KEY_BYTES]) and imports it. */ fun import(uri: Uri, context: Context) { if (busy) return busy = true val resolver = context.applicationContext.contentResolver + val tooLarge = context.getString(R.string.pgp_key_import_too_large) + val downgrade = context.getString(R.string.pgp_key_import_downgrade) viewModelScope.launch { try { val bytes = withContext(dispatcherProvider.io()) { - resolver.openInputStream(uri)?.use { it.readBytes() } + resolver.openInputStream(uri)?.use { + readAtMost(it, MAX_KEY_BYTES) ?: throw IOException(tooLarge) + } } - if (bytes == null) importResult = KeyImport.Failed(null) else add(bytes, replace = false) + if (bytes == null) importResult = KeyImport.Failed(null) + else add(bytes, replace = false, downgrade) } catch (e: IOException) { importResult = KeyImport.Failed(e.message) } catch (e: SecurityException) { @@ -168,22 +175,34 @@ constructor( importResult = null } - /** Imports [bytes]; they are zeroed afterwards unless the replace question keeps them. */ - private suspend fun add(bytes: ByteArray, replace: Boolean) { - val result = keyManager.addKey(PGPKey(bytes), replace) - if (!replace && result.isErr && result.error is KeyAlreadyExistsException) { - pending = bytes - importResult = KeyImport.Exists - return + /** + * Imports [bytes]; they are zeroed afterwards, also on failure or cancellation, unless the + * replace question keeps them. [downgrade] is the message for a public key over a secret one. + */ + private suspend fun add(bytes: ByteArray, replace: Boolean, downgrade: String? = null) { + var keep = false + try { + val result = keyManager.addKey(PGPKey(bytes), replace) + if (!replace && result.isErr && result.error is KeyAlreadyExistsException) { + // A question still open from an earlier pick is superseded: zero its bytes first. + dropPending() + pending = bytes + keep = true + importResult = KeyImport.Exists + return + } + // The ID is read before the bytes are zeroed: the returned key shares them. + importResult = + if (result.isOk) { + val id = withContext(dispatcherProvider.default()) { KeyUtils.tryGetId(result.value) } + KeyImport.Imported(id?.toString().orEmpty()) + } else if (result.error is KeyDowngradeException) { + KeyImport.Failed(downgrade ?: result.error.message) + } else KeyImport.Failed(result.error.message) + if (result.isOk) updateKeySet() + } finally { + if (!keep) bytes.fill(0) } - // The ID is read before the bytes are zeroed: the returned key shares them. - importResult = - if (result.isOk) { - val id = withContext(dispatcherProvider.default()) { KeyUtils.tryGetId(result.value) } - KeyImport.Imported(id?.toString().orEmpty()) - } else KeyImport.Failed(result.error.message) - bytes.fill(0) - if (result.isOk) updateKeySet() } /** @@ -223,3 +242,6 @@ constructor( dropPending() } } + +/** The largest key file imported: far above any real key, small enough to hold in memory. */ +private const val MAX_KEY_BYTES = 1024 * 1024 diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt index 8c7b20a1..ade23aa2 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt @@ -9,8 +9,11 @@ import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.util.settings.AuthMode import java.io.File import java.io.IOException +import java.io.InputStream +import java.nio.channels.Channels import java.nio.file.Files import java.nio.file.LinkOption +import java.nio.file.StandardOpenOption import java.util.Locale /** Why a repository URL is questioned before it is saved, as the old server screen checked it. */ @@ -73,9 +76,17 @@ fun cloneTarget(dir: File): CloneTarget { } } -/** The recipients named by a `.gpg-id` file's [text]; blank and unparsable lines are skipped. */ +/** + * The recipients named by a `.gpg-id` file's [text], read as pass reads it: a `#` starts a comment, + * and a trailing `!` (GnuPG's "this exact subkey") is dropped. Blank and unparsable lines are + * skipped. + */ fun storeRecipients(text: String): List = - text.lines().map(String::trim).filter(String::isNotEmpty).mapNotNull(PGPIdentifier::fromString) + text + .lines() + .map { it.substringBefore('#').trim().removeSuffix("!") } + .filter(String::isNotEmpty) + .mapNotNull(PGPIdentifier::fromString) /** * A `.gpg-id` naming [keys]: each 16-hex long key ID in uppercase on a line of its own, the format @@ -91,12 +102,34 @@ private const val MAX_GPG_ID_BYTES = 64 * 1024 * than 64 KiB or unreadable. Blocking: call it off the main thread. */ fun readGpgId(dir: File): String? { - val file = File(dir, ".gpg-id") - val path = file.toPath() + val path = File(dir, ".gpg-id").toPath() + if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) return null + // Opened without following a link too: one swapped in after the check is refused. + val options = setOf(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS) return try { - if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) null - else if (Files.size(path) > MAX_GPG_ID_BYTES) null else file.readText() + Channels.newInputStream(Files.newByteChannel(path, options)) + .use { readAtMost(it, MAX_GPG_ID_BYTES) } + ?.decodeToString() } catch (_: IOException) { null } } + +/** + * At most [limit] bytes of [input], or null when it holds more. Reads at most [limit] + 1 bytes, + * whatever the stream's size, and zeroes its buffer: the bytes may be a private key. + */ +fun readAtMost(input: InputStream, limit: Int): ByteArray? { + val buffer = ByteArray(limit + 1) + try { + var size = 0 + while (size < buffer.size) { + val read = input.read(buffer, size, buffer.size - size) + if (read < 0) break + size += read + } + return if (size > limit) null else buffer.copyOf(size) + } finally { + buffer.fill(0) + } +} diff --git a/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt index d70b06af..bc3e9474 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/SshKeyViewModel.kt @@ -17,10 +17,8 @@ import app.passwordstore.R import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.git.sshj.SshKey import dagger.hilt.android.lifecycle.HiltViewModel -import java.io.IOException -import java.security.GeneralSecurityException -import java.security.ProviderException import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -83,11 +81,10 @@ class SshKeyViewModel @Inject constructor(private val dispatcherProvider: Dispat withContext(dispatcherProvider.io()) { SshKey.import(uri) } imported = true version++ - } catch (e: IOException) { - failure = e.message.orEmpty() - } catch (e: IllegalArgumentException) { - failure = e.message.orEmpty() - } catch (e: SecurityException) { + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Any failure of a provider or of sshj, not only I/O: an uncaught one would crash the app. failure = e.message.orEmpty() } finally { busy = false @@ -114,11 +111,10 @@ class SshKeyViewModel @Inject constructor(private val dispatcherProvider: Dispat SshKey.sshPublicKey.orEmpty() } version++ - } catch (e: GeneralSecurityException) { - failure = e.message.orEmpty() - } catch (e: ProviderException) { - failure = e.message.orEmpty() - } catch (e: IOException) { + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Keystore and provider failures come as many exception types; none may crash the app. failure = e.message.orEmpty() } finally { busy = false diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt index 8d5519a8..06cf5bc8 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt @@ -29,6 +29,8 @@ import com.github.michaelbull.result.getOrElse import com.github.michaelbull.result.runCatching import java.io.File import java.io.IOException +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.security.KeyFactory import java.security.KeyPair import java.security.KeyPairGenerator @@ -203,15 +205,20 @@ object SshKey { } private fun delete() { - androidKeystore.deleteEntry(KEYSTORE_ALIAS) if (privateKeyFile.isFile) { privateKeyFile.delete() } + deleteKeyExtras() + type = null + } + + /** Everything of the current key but its private key file. */ + private fun deleteKeyExtras() { + androidKeystore.deleteEntry(KEYSTORE_ALIAS) if (publicKeyFile.isFile) { publicKeyFile.delete() } context.getEncryptedGitPrefs().edit { remove(PreferenceKeys.SSH_KEY_LOCAL_PASSPHRASE) } - type = null } fun import(uri: Uri) { @@ -241,12 +248,23 @@ object SshKey { ) // At this point, we are reasonably confident that we have actually been provided a private - // key and delete the old key. - delete() - // Canonicalize line endings to '\n'. - privateKeyFile.writeText(lines.joinToString("\n")) - + // key. It replaces the old one atomically (an I/O error keeps the old key), then the old key's + // Keystore entry, public key and passphrase go. + val temp = File(privateKeyFile.parentFile, ".${privateKeyFile.name}.tmp") + try { + // Canonicalize line endings to '\n'. + temp.writeText(lines.joinToString("\n")) + Files.move( + temp.toPath(), + privateKeyFile.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } finally { + temp.delete() + } type = Type.Imported + deleteKeyExtras() } /** Deletes an Ed25519 key wrapped by androidx security-crypto, which can no longer be read. */ diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index c2edc768..192345cb 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -372,4 +372,7 @@ Chiave privata importata Generata su questo dispositivo Clonazione… + Impossibile svuotare la cartella dell\'archivio, la clonazione non è partita. + Il file supera 1 MiB: non è una chiave PGP. + Una chiave privata con questo ID è già importata. La sola chiave pubblica non può sostituirla: elimina prima la chiave privata, se vuoi davvero farlo. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d53700a8..8c046ca5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -407,4 +407,7 @@ Imported private key Generated on this device Cloning… + Could not empty the store folder, so the clone did not start. + The file is larger than 1 MiB: not a PGP key. + A private key with this ID is already imported. Its public key alone cannot replace it: delete the private key first if you really want to. diff --git a/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt index 8d8243eb..53ea80cf 100644 --- a/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt +++ b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt @@ -88,6 +88,25 @@ class SetupModelTest { assertEquals(emptyList(), storeRecipients("")) } + @Test + fun storeRecipientsDropCommentsAndExactSubkeyMarks() { + val ids = + storeRecipients( + "# store key\nF8993A789CDFB014! # laptop\n79E8208280490C77!\nalice@example.org#work\n # \n" + ) + assertEquals( + listOf("f8993a789cdfb014", "79e8208280490c77", "alice@example.org"), + ids.map(PGPIdentifier::toString), + ) + } + + @Test + fun readAtMostRefusesMoreThanTheLimit() { + assertEquals("abc", readAtMost("abc".byteInputStream(), 3)?.decodeToString()) + assertEquals("", readAtMost("".byteInputStream(), 3)?.decodeToString()) + assertNull(readAtMost("abcd".byteInputStream(), 3)) + } + @Test fun gpgIdTextIsUppercaseKeyIdsOnePerLine() { val ids = diff --git a/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt b/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt index af6145d0..9589ceec 100644 --- a/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt +++ b/crypto/common/src/main/kotlin/app/passwordstore/crypto/errors/CryptoException.kt @@ -39,6 +39,10 @@ public class KeyNotFoundException(keyId: String) : public class KeyAlreadyExistsException(keyId: String) : KeyManagerException("Pre-existing key was found for $keyId") +/** Attempting to replace a stored secret key with a public-only key of the same `keyId`. */ +public class KeyDowngradeException(keyId: String) : + KeyManagerException("A secret key is stored for $keyId; a public key cannot replace it") + /** Sealed exception types for [app.passwordstore.crypto.CryptoHandler]. */ public sealed class CryptoHandlerException(message: String? = null, cause: Throwable? = null) : CryptoException(message, cause) diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt index 311e9049..9acd0489 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt @@ -41,6 +41,8 @@ public object KeyUtils { } /** True when [key] parses as a secret key ring, the only kind that can decrypt. */ + // ponytail: GnuPG stub and smartcard keyrings (no usable secret material) count as secret too. + // Upgrade path: check for a secret subkey usable for decryption. public fun isSecretKey(key: PGPKey): Boolean = tryParseKeyring(key) is PGPSecretKeyRing /** diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt index 6f852af4..ef58ad19 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt @@ -14,6 +14,7 @@ import app.passwordstore.crypto.errors.InvalidKeyException import app.passwordstore.crypto.errors.KeyAlreadyExistsException import app.passwordstore.crypto.errors.KeyDeletionFailedException import app.passwordstore.crypto.errors.KeyDirectoryUnavailableException +import app.passwordstore.crypto.errors.KeyDowngradeException import app.passwordstore.crypto.errors.KeyNotFoundException import app.passwordstore.crypto.errors.NoKeysAvailableException import app.passwordstore.crypto.errors.UnusableKeyException @@ -59,6 +60,9 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) : keyFile.writeBytes(keyBytes) return@runSuspendCatching key } + // Never with replace either: it would delete the only copy of the private key. + existingKeyRing is PGPSecretKeyRing && incomingKeyRing is PGPPublicKeyRing -> + throw KeyDowngradeException(tryGetId(key)?.toString() ?: "Failed to retrieve key ID") } // Check for replace flag first and if it is false, throw an error if (!replace) diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt index 0525c28a..81a666f4 100644 --- a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt +++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt @@ -9,6 +9,7 @@ import app.passwordstore.crypto.KeyUtils.tryGetId import app.passwordstore.crypto.PGPIdentifier.KeyId import app.passwordstore.crypto.PGPIdentifier.UserId import app.passwordstore.crypto.errors.KeyAlreadyExistsException +import app.passwordstore.crypto.errors.KeyDowngradeException import app.passwordstore.crypto.errors.KeyNotFoundException import app.passwordstore.crypto.errors.NoKeysAvailableException import app.passwordstore.crypto.errors.UnusableKeyException @@ -176,6 +177,16 @@ class PGPKeyManagerTest { assertTrue(keyManager.addKey(publicKey).isErr) } + @Test + fun replaceSecretKeyWithPublicKeyIsRefusedEvenWithReplace() = + runTest(dispatcher) { + keyManager.addKey(secretKey).unwrap() + assertIs(keyManager.addKey(publicKey, replace = true).unwrapError()) + assertIs(keyManager.addKey(publicKey, replace = false).unwrapError()) + // The private key is still the stored one. + assertContentEquals(secretKey.contents, keyManager.getAllKeys().unwrap().single().contents) + } + @Test fun replacePublicKeyWithSecretKey() = runTest(dispatcher) { diff --git a/docs/superpowers/plans/2026-11-03-compose-setup.md b/docs/superpowers/plans/2026-11-03-compose-setup.md index e4bcd5da..d62aeff6 100644 --- a/docs/superpowers/plans/2026-11-03-compose-setup.md +++ b/docs/superpowers/plans/2026-11-03-compose-setup.md @@ -1555,7 +1555,11 @@ fun GitServerDestination( viewModel.progress = R.string.git_clone_progress activity.lifecycleScope.launch { try { - viewModel.clearStore(root) + // A folder that could not be emptied is reported as such, not as a vague clone error. + viewModel.clearStore(root)?.let { failure -> + say(activity.getString(failure)) + return@launch + } activity .launchGitOperation(GitOp.CLONE) .fold( @@ -3757,7 +3761,7 @@ deleting `$OUT` if a visual check is wanted (copy them elsewhere first). `sshKeySummary`, `SshKeyType`, `SshKeyViewModel.import/generate/fail/dismissFailure/announced`, `type`, `protect`, `busy`, `version`, `imported`, `generated`, `failure` (Task 2) match Task 4; `GitServerViewModel.url/changeUrl/authMode/progress/cloned/hasHostKey/sshKey/refresh/ - clearHostKey/save/clearStore` (suspend) `/finishClone` (not suspend, launches in + clearHostKey/save/clearStore` (suspend, returns null or a failure string resource) `/finishClone` (not suspend, launches in `viewModelScope`) `/cloneEnded` (Task 2) match Task 3; `KeyRow(userId, keyId)` (no `gpgId` member any more), `KeyImport`, `PgpKeysViewModel.keys/recipients/loadRecipients/busy/ importResult/done/import/replace/dismissImport/deleteKey/createStore/markDone` (Task 2) match -- cgit v1.2.3 From 2b73e524d9624897f675ccc14f614fc450a7565f Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 14:44:58 +0100 Subject: fix(app): address the step 5 review and emulator findings - A store a cancelled attempt already made for the chosen key (its .gpg-id names exactly that key) no longer counts as occupied: choosing the key again commits and moves on instead of refusing. - SSH key import reads at most 100 kB whatever the provider reports, like the PGP import, instead of reading the stream whole. - PGPKeyManager replaces a key through a temporary file moved over it atomically, so a failed write keeps the old key; listings only read .key files, so a stray temporary file is never taken for a key. - "View generated public SSH key" goes through the settings result launcher, so the preference visibility refreshes afterwards. - A plain start from LaunchActivity replaces the task: an open MainActivity (e.g. one whose store vanished) is no longer left below the new one, and one back leaves the app. - The PGP keys screen's import FAB carries its content description itself; M3 cleared the icon's. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/main/LaunchActivity.kt | 3 ++ .../ui/settings/RepositorySettings.kt | 2 +- .../app/passwordstore/ui/setup/PgpKeysScreen.kt | 14 ++++++---- .../app/passwordstore/ui/setup/PgpKeysViewModel.kt | 10 +++++-- .../java/app/passwordstore/ui/setup/SetupModel.kt | 7 +++++ .../java/app/passwordstore/util/git/sshj/SshKey.kt | 14 +++++++++- .../app/passwordstore/ui/setup/SetupModelTest.kt | 10 +++++++ .../app/passwordstore/crypto/PGPKeyManager.kt | 32 ++++++++++++++++++---- .../app/passwordstore/crypto/PGPKeyManagerTest.kt | 5 ++++ 9 files changed, 81 insertions(+), 16 deletions(-) (limited to 'app/src/test/java') diff --git a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt index befd37f9..66b5cf1f 100644 --- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt @@ -56,6 +56,9 @@ class LaunchActivity : AppCompatActivity() { val intentToStart = Intent(this, MainActivity::class.java) if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir)) intentToStart.putExtra(BasePGPActivity.EXTRA_FILE_PATH, filePath) + // A plain start replaces the task, so an instance already open (one whose store vanished, say) + // is not left below the new one. An entry opens on top, back returns to where the user was. + else intentToStart.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) startActivity(intentToStart) Handler(Looper.getMainLooper()).postDelayed({ finish() }, if (noAuth) 0L else 500L) diff --git a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt index af50b3ea..23dd2829 100644 --- a/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt +++ b/app/src/main/java/app/passwordstore/ui/settings/RepositorySettings.kt @@ -110,7 +110,7 @@ class RepositorySettings(private val activity: FragmentActivity) : SettingsProvi titleRes = R.string.pref_ssh_see_key_title visible = PasswordRepository.isGitRepo() && SshKey.canShowSshPublicKey onClick { - activity.startActivity(MainActivity.setupIntent(activity, SetupStart.Ssh)) + generateSshKey.launch(MainActivity.setupIntent(activity, SetupStart.Ssh)) true } } diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt index 34e5bed6..11fd979c 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysScreen.kt @@ -43,6 +43,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -121,11 +123,13 @@ fun PgpKeysScreen( ) }, floatingActionButton = { - // M3 clears the label's semantics; the FAB takes its description from the icon. + // M3 clears the semantics of the FAB's content, label and icon alike: describe the FAB. + val label = stringResource(R.string.pref_import_pgp_key_title) ExtendedFloatingActionButton( - text = { Text(stringResource(R.string.pref_import_pgp_key_title)) }, - icon = { Icon(Icons.Filled.Add, stringResource(R.string.pref_import_pgp_key_title)) }, + text = { Text(label) }, + icon = { Icon(Icons.Filled.Add, null) }, onClick = { if (!busy) onImport() }, + modifier = Modifier.semantics { contentDescription = label }, ) }, ) { padding -> @@ -254,8 +258,8 @@ fun PgpKeysDestination( if (uri != null) viewModel.import(uri, activity) } - // ponytail: a theme or locale change in the middle cancels the commit; the next commit picks up - // the new `.gpg-id`, and choosing the key again re-creates nothing that would conflict. + // ponytail: a theme or locale change in the middle cancels the commit. Choosing the same key + // again finds the store already made for it (createdFor), commits the `.gpg-id` and moves on. fun choose(row: KeyRow) { if (viewModel.busy) return viewModel.busy = true diff --git a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt index d270275f..8f1b52ec 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/PgpKeysViewModel.kt @@ -209,12 +209,16 @@ constructor( /** * Creates the local store at [root] for [row]: a git repository and a `.gpg-id` naming the key by * its uppercase long key ID ([gpgIdText]). Returns null, or why it failed; [occupied] is the - * reason given when [root] already holds a store. + * reason given when [root] already holds a store, unless that store was made for [row] already. */ suspend fun createStore(root: File, row: KeyRow, occupied: String): String? = withContext(dispatcherProvider.io()) { - // A restored, already-cloned store must keep its .gpg-id. - if (root.isDirectory && cloneTarget(root) == CloneTarget.Occupied) return@withContext occupied + if (root.isDirectory && cloneTarget(root) == CloneTarget.Occupied) { + // Made by an earlier attempt for this key whose commit a recreation cancelled: done. + if (createdFor(root, row.keyId)) return@withContext null + // A restored, already-cloned store must keep its .gpg-id. + return@withContext occupied + } try { check(root.isDirectory || root.mkdirs()) { "Could not create $root" } PasswordRepository.createRepository(root) diff --git a/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt index ade23aa2..4e32505f 100644 --- a/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt +++ b/app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt @@ -95,6 +95,13 @@ fun storeRecipients(text: String): List = fun gpgIdText(keys: List): String = keys.joinToString("") { "${it.toString().uppercase(Locale.ROOT)}\n" } +/** + * Whether [dir] holds a store already made for [key]: its `.gpg-id` is exactly what creating one + * for [key] writes. Blocking: call it off the main thread. + */ +fun createdFor(dir: File, key: PGPIdentifier.KeyId): Boolean = + readGpgId(dir) == gpgIdText(listOf(key)) + private const val MAX_GPG_ID_BYTES = 64 * 1024 /** diff --git a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt index 06cf5bc8..7c8f5ba5 100644 --- a/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt +++ b/app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt @@ -16,6 +16,7 @@ import android.util.Base64 import androidx.core.content.edit import app.passwordstore.Application import app.passwordstore.R +import app.passwordstore.ui.setup.readAtMost import app.passwordstore.util.crypto.keystoreAesKey import app.passwordstore.util.crypto.requireUserAuthentication import app.passwordstore.util.crypto.seal @@ -239,7 +240,18 @@ object SshKey { val sshKeyInputStream = context.contentResolver.openInputStream(uri) ?: throw IOException(context.getString(R.string.ssh_key_does_not_exist)) - val lines = sshKeyInputStream.use { `is` -> `is`.bufferedReader().readLines() } + // Bounded whatever the provider claimed above: a larger stream is refused, not read whole. + val bytes = + sshKeyInputStream.use { readAtMost(it, 100_000) } + ?: throw IllegalArgumentException( + context.getString(R.string.ssh_key_import_error_not_an_ssh_key_message) + ) + val lines = + try { + bytes.inputStream().bufferedReader().readLines() + } finally { + bytes.fill(0) + } // Private key markers and a format sshj can read; the current key stays when this fails. if (!isSshPrivateKey(lines)) diff --git a/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt index 53ea80cf..9ccddbd3 100644 --- a/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt +++ b/app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt @@ -137,4 +137,14 @@ class SetupModelTest { val folder = File(root, "folder").apply { File(this, ".gpg-id").mkdirs() } assertNull(readGpgId(folder)) } + + @Test + fun createdForMatchesOnlyTheChosenKeysGpgId() { + val key = PGPIdentifier.fromString("0xf8993a789cdfb014") as PGPIdentifier.KeyId + assertEquals(false, createdFor(root, key)) + File(root, ".gpg-id").writeText(gpgIdText(listOf(key))) + assertEquals(true, createdFor(root, key)) + File(root, ".gpg-id").writeText("F8993A789CDFB014\nB950AE2813841585\n") + assertEquals(false, createdFor(root, key)) + } } diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt index ef58ad19..33d61343 100644 --- a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt @@ -22,6 +22,8 @@ import com.github.michaelbull.result.Result import com.github.michaelbull.result.coroutines.runSuspendCatching import com.github.michaelbull.result.unwrap import java.io.File +import java.nio.file.Files +import java.nio.file.StandardCopyOption import javax.inject.Inject import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext @@ -51,13 +53,13 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) : tryParseKeyring(PGPKey(existingKeyBytes)) ?: throw InvalidKeyException when { existingKeyRing is PGPPublicKeyRing && incomingKeyRing is PGPSecretKeyRing -> { - keyFile.writeBytes(key.contents) + writeAtomically(keyFile, key.contents) return@runSuspendCatching key } existingKeyRing is PGPPublicKeyRing && incomingKeyRing is PGPPublicKeyRing -> { val updatedPublicKey = PGPainless.mergeCertificate(existingKeyRing, incomingKeyRing) val keyBytes = PGPainless.asciiArmor(updatedPublicKey).encodeToByteArray() - keyFile.writeBytes(keyBytes) + writeAtomically(keyFile, keyBytes) return@runSuspendCatching key } // Never with replace either: it would delete the only copy of the private key. @@ -69,10 +71,9 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) : throw KeyAlreadyExistsException( tryGetId(key)?.toString() ?: "Failed to retrieve key ID" ) - if (!keyFile.delete()) throw KeyDeletionFailedException } - keyFile.writeBytes(key.contents) + writeAtomically(keyFile, key.contents) key } @@ -96,7 +97,7 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) : withContext(dispatcher) { runSuspendCatching { if (!keyDirExists()) throw KeyDirectoryUnavailableException - val keyFiles = keyDir.listFiles() + val keyFiles = keyDir.listFiles { file -> file.extension == KEY_EXTENSION } if (keyFiles.isNullOrEmpty()) throw NoKeysAvailableException val keys = keyFiles.map { file -> PGPKey(file.readBytes()) } @@ -134,7 +135,7 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) : withContext(dispatcher) { runSuspendCatching { if (!keyDirExists()) throw KeyDirectoryUnavailableException - val keyFiles = keyDir.listFiles() + val keyFiles = keyDir.listFiles { file -> file.extension == KEY_EXTENSION } if (keyFiles.isNullOrEmpty()) return@runSuspendCatching emptyList() keyFiles.map { keyFile -> PGPKey(keyFile.readBytes()) }.toList() } @@ -143,6 +144,25 @@ constructor(filesDir: String, private val dispatcher: CoroutineDispatcher) : /** @see KeyManager.getKeyById */ override suspend fun getKeyId(key: PGPKey): PGPIdentifier? = tryGetId(key) + /** + * Writes [bytes] to a temporary file next to [file] and moves it over [file] in one step, so a + * failed write keeps the key that was there. + */ + private fun writeAtomically(file: File, bytes: ByteArray) { + val temp = File(file.parentFile, ".${file.name}.tmp") + try { + temp.writeBytes(bytes) + Files.move( + temp.toPath(), + file.toPath(), + StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE, + ) + } finally { + temp.delete() + } + } + /** Checks if [keyDir] exists and attempts to create it if not. */ private fun keyDirExists(): Boolean { return keyDir.exists() || keyDir.mkdirs() diff --git a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt index 81a666f4..587f7239 100644 --- a/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt +++ b/crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPKeyManagerTest.kt @@ -72,6 +72,11 @@ class PGPKeyManagerTest { val keyId = keyManager.getKeyId(keyManager.addKey(secretKey, true).unwrap()) assertEquals(KeyId(CryptoConstants.KEY_ID), keyId) + // Replaced through a temporary file that does not stay behind. + assertEquals(listOf("$keyId.${PGPKeyManager.KEY_EXTENSION}"), keysDir.list()?.toList()) + // A stray temporary file left by a crash is not listed as a key. + File(keysDir, ".$keyId.${PGPKeyManager.KEY_EXTENSION}.tmp").writeText("partial") + assertEquals(1, keyManager.getAllKeys().unwrap().size) } @Test -- cgit v1.2.3 From cc880cf62ea8391f693f034858578cd4b693043b Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 3 Nov 2026 18:36:42 +0100 Subject: feat(app): drop two sort orders, fix the proxy user name The Compose list shows folders and entries in separate groups, so the files first and type independent sort orders did nothing visible. They are removed; a stored one migrates to folders first, and any unknown value reads as folders first instead of throwing. The OpenKeychain SSH key ID, which nothing reads any more, is removed once. GitSettings wrote the proxy user name to the proxy preferences but read it from the main settings, so ProxyUtils never authenticated. Adds the pure parts of the settings port: proxy port parsing, HEAD status and the commit log. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/settings/SettingsModel.kt | 79 +++++++++++++++++++ .../app/passwordstore/util/settings/GitSettings.kt | 2 +- .../app/passwordstore/util/settings/Migrations.kt | 19 +++++ .../util/settings/PasswordSortOrder.kt | 17 ++-- .../viewmodel/SearchableRepositoryViewModel.kt | 7 +- app/src/main/res/values-de/strings.xml | 2 - app/src/main/res/values-fr/strings.xml | 2 - app/src/main/res/values-gl/strings.xml | 2 - app/src/main/res/values-it/strings.xml | 2 - app/src/main/res/values-ko-rKR/strings.xml | 2 - app/src/main/res/values-pl-rPL/strings.xml | 2 - app/src/main/res/values-pt-rBR/strings.xml | 2 - app/src/main/res/values-ru/strings.xml | 2 - app/src/main/res/values-zh-rCN/strings.xml | 2 - app/src/main/res/values/arrays.xml | 4 - app/src/main/res/values/strings.xml | 2 - .../passwordstore/ui/settings/SettingsModelTest.kt | 90 ++++++++++++++++++++++ .../passwordstore/util/settings/GitSettingsTest.kt | 27 +++++++ .../passwordstore/util/settings/MigrationsTest.kt | 25 ++++++ .../util/settings/PasswordSortOrderTest.kt | 26 +++++++ 20 files changed, 274 insertions(+), 42 deletions(-) create mode 100644 app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt create mode 100644 app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt create mode 100644 app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt create mode 100644 app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt (limited to 'app/src/test/java') diff --git a/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt new file mode 100644 index 00000000..eca6770c --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/settings/SettingsModel.kt @@ -0,0 +1,79 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import app.passwordstore.util.extensions.hash +import app.passwordstore.util.extensions.time +import app.passwordstore.util.git.GitCommit +import java.io.IOException +import logcat.LogPriority.ERROR +import logcat.asLog +import logcat.logcat +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.api.errors.GitAPIException +import org.eclipse.jgit.api.errors.JGitInternalException +import org.eclipse.jgit.lib.Constants +import org.eclipse.jgit.lib.Repository +import org.eclipse.jgit.lib.RepositoryState + +private const val TAG = "SettingsModel" + +/** + * The proxy port typed as [text]: -1 when empty (no port, as stored before any was set), null when + * it is not a port number from 1 to 65535. + */ +fun proxyPort(text: String): Int? { + val trimmed = text.trim() + if (trimmed.isEmpty()) return -1 + return trimmed.toIntOrNull()?.takeIf { it in 1..65535 } +} + +/** Where HEAD points, as the git config screen reports it. */ +sealed interface HeadStatus { + data class Branch(val name: String) : HeadStatus + + data class Detached(val hash: String) : HeadStatus + + data object Missing : HeadStatus +} + +/** [repo]'s HEAD: a branch (also one without commits yet), a detached commit, or missing. */ +fun headStatus(repo: Repository): HeadStatus = + try { + val head = repo.findRef(Constants.HEAD) + when { + head == null -> HeadStatus.Missing + head.isSymbolic -> HeadStatus.Branch(Repository.shortenRefName(head.target.name)) + else -> + head.objectId?.let { HeadStatus.Detached(it.abbreviate(8).name()) } ?: HeadStatus.Missing + } + } catch (e: IOException) { + logcat(TAG, ERROR) { e.asLog() } + HeadStatus.Missing + } + +/** Whether a rebase or a merge is in progress, so "Abort rebase" has something to abort. */ +fun needsAbort(repo: Repository): Boolean = + repo.repositoryState.let { it.isRebasing || it == RepositoryState.MERGING } + +/** + * The commits reachable from HEAD, newest first; empty without a repository or a commit. Blocking: + * call it off the main thread. + */ +// ponytail: the whole log at once, as the old screen did (tested there with 1000 commits); page it +// if a store's history ever makes this slow. +fun gitLog(repo: Repository?): List { + if (repo == null) return emptyList() + return try { + Git(repo).log().call().map { GitCommit(it.hash, it.shortMessage, it.authorIdent.name, it.time) } + } catch (e: GitAPIException) { + logcat(TAG, ERROR) { e.asLog() } + emptyList() + } catch (e: JGitInternalException) { + logcat(TAG, ERROR) { e.asLog() } + emptyList() + } +} diff --git a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt index d315daf3..004028c4 100644 --- a/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt +++ b/app/src/main/java/app/passwordstore/util/settings/GitSettings.kt @@ -108,7 +108,7 @@ constructor( } var proxyUsername - get() = settings.getString(PreferenceKeys.PROXY_USERNAME) + get() = proxySettings.getString(PreferenceKeys.PROXY_USERNAME) set(value) { proxySettings.edit { putString(PreferenceKeys.PROXY_USERNAME, value) } } diff --git a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt index 06075655..82847f02 100644 --- a/app/src/main/java/app/passwordstore/util/settings/Migrations.kt +++ b/app/src/main/java/app/passwordstore/util/settings/Migrations.kt @@ -32,6 +32,8 @@ fun runMigrations(filesDirPath: String, sharedPrefs: SharedPreferences, gitSetti migrateToDiceware(sharedPrefs) removeExternalStorageProperties(sharedPrefs) removeCurrentBranchValue(sharedPrefs) + migrateRemovedSortOrders(sharedPrefs) + removeOpenKeystoreKeyId(sharedPrefs) } /** @@ -194,3 +196,20 @@ private fun removeExternalStorageProperties(prefs: SharedPreferences) { } } } + +/** + * "Files first" and "type independent" were removed with the Compose list, which shows folders and + * entries in separate groups: a stored one becomes folders first, the list's default. + */ +private fun migrateRemovedSortOrders(sharedPrefs: SharedPreferences) { + val stored = sharedPrefs.getString(PreferenceKeys.SORT_ORDER) ?: return + if (stored != "FILE_FIRST" && stored != "INDEPENDENT") return + logcat(TAG, INFO) { "Replacing the removed sort order $stored" } + sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, PasswordSortOrder.FOLDER_FIRST.name) } +} + +/** OpenKeychain SSH support is gone and nothing reads the remembered key ID any more. */ +private fun removeOpenKeystoreKeyId(sharedPrefs: SharedPreferences) { + if (!sharedPrefs.contains(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)) return + sharedPrefs.edit { remove(PreferenceKeys.SSH_OPENKEYSTORE_KEYID) } +} diff --git a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt index 224d9d66..921e9bc4 100644 --- a/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt +++ b/app/src/main/java/app/passwordstore/util/settings/PasswordSortOrder.kt @@ -18,11 +18,6 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator) (p1.type + p1.name).compareTo(p2.type + p2.name, ignoreCase = true) } ), - INDEPENDENT( - Comparator { p1: PasswordItem, p2: PasswordItem -> - p1.name.compareTo(p2.name, ignoreCase = true) - } - ), RECENTLY_USED( Comparator { p1: PasswordItem, p2: PasswordItem -> val recentHistory = @@ -36,18 +31,18 @@ enum class PasswordSortOrder(val comparator: java.util.Comparator) else -> p1.name.compareTo(p2.name, ignoreCase = true) } } - ), - FILE_FIRST( - Comparator { p1: PasswordItem, p2: PasswordItem -> - (p2.type + p1.name).compareTo(p1.type + p2.name, ignoreCase = true) - } ); companion object { + /** + * The stored sort order; anything else (the removed "files first" and "type independent", a + * value from another version) reads as [FOLDER_FIRST]. + */ @JvmStatic fun getSortOrder(settings: SharedPreferences): PasswordSortOrder { - return valueOf(settings.getString(PreferenceKeys.SORT_ORDER) ?: FOLDER_FIRST.name) + val stored = settings.getString(PreferenceKeys.SORT_ORDER) + return entries.firstOrNull { it.name == stored } ?: FOLDER_FIRST } } } diff --git a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt index ca570dc4..f8cbf1e4 100644 --- a/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt +++ b/app/src/main/java/app/passwordstore/util/viewmodel/SearchableRepositoryViewModel.kt @@ -71,12 +71,7 @@ private fun PasswordItem.Companion.makeComparator( directoryStructure: DirectoryStructure, ): Comparator { return when (typeSortOrder) { - PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } - // In order to let INDEPENDENT not distinguish between items based on their type, we - // simply - // declare them all equal at this stage. - PasswordSortOrder.INDEPENDENT -> Comparator { _, _ -> 0 } - PasswordSortOrder.FILE_FIRST -> compareByDescending { it.type } + PasswordSortOrder.FOLDER_FIRST -> compareBy { it.type } PasswordSortOrder.RECENTLY_USED -> PasswordSortOrder.RECENTLY_USED.comparator } .then( diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 493391a8..618aac0d 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -96,8 +96,6 @@ Findet Passwörter auch in Unterordnern. Passwortsortierung Ordner zuerst - Dateien zuerst - typunabhängig Zuletzt verwendet Automatisch ausfüllen Autofill aktivieren diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index d2a469e7..4cc7d29d 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -94,8 +94,6 @@ Cherche le mot de passe dans tous les sous-répertoires du répertoire actuel. Ordre de tri des mots de passe Dossiers en premier - Fichiers en premier - Indifférent au type d\'entrée Récemment utilisé Saisie automatique Saisie automatique diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index ad4c663d..34b1ce6a 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -97,8 +97,6 @@ Organización dos contrasinais Orde para mostrar contrasinais Primeiro cartafoles - Primeiro ficheiros - Independentemente do tipo Usadas recentemente Completado automático Activar completado automático diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8edc9d3d..9b67482c 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -94,8 +94,6 @@ Trova ricorsivamente le password della directory corrente. Ordine password Prima le cartelle - Prima i file - Indipendente dal tipo Usato di recente Compilazione automatica Attiva la compilazione automatica diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index aaa438dd..06e0a555 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -95,8 +95,6 @@ 암허 파일 정리 암호 정렬 순서 폴더 우선 - 파일 우선 - 타입순 최신순 자동완성 자동완성 활성화 diff --git a/app/src/main/res/values-pl-rPL/strings.xml b/app/src/main/res/values-pl-rPL/strings.xml index 815cf41c..6bdb966c 100644 --- a/app/src/main/res/values-pl-rPL/strings.xml +++ b/app/src/main/res/values-pl-rPL/strings.xml @@ -94,8 +94,6 @@ Organizacja plików haseł Kolejność sortowania haseł Najpierw foldery - Najpierw pliki - Niezależnie od typu Ostatnio używane Autouzupełnianie Włącz autouzupełnianie diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index a1d1a38c..8f40c831 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -96,8 +96,6 @@ Encontrar senhas do diretório corrente recursivamente. Ordenação da Senha Pastas primeiro - Arquivos primeiro - Tipo independente Usado recentemente Preenchimento Automático Ativar preenchimento automático diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index d0934a4d..af3078bc 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -99,8 +99,6 @@ Рекурсивный поиск паролей в текущей директории Порядок сортировки паролей Сначала папки - Сначала файлы - Типонезависимый Недавно использованные Автозаполнение Включить автозаполнение diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index ddcdda1a..959d5fac 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -95,8 +95,6 @@ 递归式寻找当前目录的密码 密码排序顺序 目录优先 - 文件优先 - 类型无关 最近使用 自动填充 启用自动填充 diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 9f44710b..2891987d 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -6,14 +6,10 @@ @string/pref_folder_first_sort_order - @string/pref_file_first_sort_order - @string/pref_type_independent_sort_order @string/pref_recently_used_sort_order FOLDER_FIRST - FILE_FIRST - INDEPENDENT RECENTLY_USED diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 59ba74cb..dc9140f8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,8 +116,6 @@ Password file organization Password sort order Folders first - Files first - Type independent Recently used Autofill Enable Autofill diff --git a/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt b/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt new file mode 100644 index 00000000..693e07dd --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/settings/SettingsModelTest.kt @@ -0,0 +1,90 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.settings + +import app.passwordstore.util.git.GitCommit +import java.io.File +import java.nio.file.Files +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import org.eclipse.jgit.api.Git +import org.eclipse.jgit.lib.PersonIdent +import org.eclipse.jgit.revwalk.RevCommit +import org.eclipse.jgit.util.FS + +class SettingsModelTest { + + private val root = Files.createTempDirectory("aps-settings").toFile() + private val identity = PersonIdent("test", "test@example.org") + + init { + // Ignore the developer's ~/.gitconfig (default branch, commit signing). + FS.DETECTED.setUserHome(root) + } + + @AfterTest fun cleanUp() = root.deleteRecursively().let {} + + private fun repo(): Git = + Git.init().setInitialBranch("master").setDirectory(File(root, "store")).call() + + private fun Git.commitFile(name: String, message: String): RevCommit { + File(repository.workTree, name).writeText(message) + add().addFilepattern(name).call() + return commit() + .setMessage(message) + .setAuthor(identity) + .setCommitter(identity) + .setSign(false) + .call() + } + + @Test + fun proxyPorts() { + assertEquals(-1, proxyPort("")) + assertEquals(-1, proxyPort(" ")) + assertEquals(8080, proxyPort("8080")) + assertEquals(8080, proxyPort(" 8080 ")) + assertEquals(1, proxyPort("1")) + assertEquals(65535, proxyPort("65535")) + assertNull(proxyPort("0")) + assertNull(proxyPort("65536")) + assertNull(proxyPort("99999999999")) + assertNull(proxyPort("-1")) + assertNull(proxyPort("80a")) + } + + @Test + fun headStatusFollowsHead() { + repo().use { git -> + // A new repository: HEAD names a branch that has no commit yet. + assertEquals(HeadStatus.Branch("master"), headStatus(git.repository)) + val first = git.commitFile("a.gpg", "Add a") + git.commitFile("b.gpg", "Add b") + assertEquals(HeadStatus.Branch("master"), headStatus(git.repository)) + assertFalse(needsAbort(git.repository)) + git.checkout().setName(first.name).call() + assertEquals(HeadStatus.Detached(first.name.take(8)), headStatus(git.repository)) + } + } + + @Test + fun gitLogIsNewestFirst() { + assertEquals(emptyList(), gitLog(null)) + repo().use { git -> + // No commit yet: JGit's NoHeadException is an empty log, not a crash. + assertEquals(emptyList(), gitLog(git.repository)) + val first = git.commitFile("a.gpg", "Add a") + val second = git.commitFile("b.gpg", "Add b") + val log = gitLog(git.repository) + assertEquals(listOf("Add b", "Add a"), log.map(GitCommit::shortMessage)) + assertEquals(listOf(second.name, first.name), log.map(GitCommit::hash)) + assertEquals("test", log[0].authorName) + } + } +} diff --git a/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt new file mode 100644 index 00000000..6b30c248 --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/settings/GitSettingsTest.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.settings + +import app.passwordstore.util.extensions.getString +import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class GitSettingsTest { + + @Test + fun proxyUsernameIsReadWhereItIsWritten() { + val settings = SPMockBuilder().createSharedPreferences() + val proxy = SPMockBuilder().createSharedPreferences() + val gitSettings = + GitSettings(settings, SPMockBuilder().createSharedPreferences(), proxy, "/nonexistent") + gitSettings.proxyUsername = "proxy-user" + assertEquals("proxy-user", gitSettings.proxyUsername) + assertEquals("proxy-user", proxy.getString(PreferenceKeys.PROXY_USERNAME)) + assertNull(settings.getString(PreferenceKeys.PROXY_USERNAME)) + } +} diff --git a/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt b/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt index 62109563..44bd12ba 100644 --- a/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt +++ b/app/src/test/java/app/passwordstore/util/settings/MigrationsTest.kt @@ -184,4 +184,29 @@ class MigrationsTest { assertFalse { sharedPrefs.contains(PreferenceKeys.GIT_EXTERNAL_REPO) } assertTrue { sharedPrefs.getBoolean(PreferenceKeys.GIT_EXTERNAL_MIGRATED, false) } } + + @Test + fun removedSortOrdersFallBackToFoldersFirst() { + val gitSettings = + GitSettings(sharedPrefs, encryptedSharedPreferences, proxySharedPreferences, filesDir) + for (removed in listOf("FILE_FIRST", "INDEPENDENT")) { + sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, removed) } + runMigrations(filesDir, sharedPrefs, gitSettings) + assertEquals("FOLDER_FIRST", sharedPrefs.getString(PreferenceKeys.SORT_ORDER)) + } + sharedPrefs.edit { putString(PreferenceKeys.SORT_ORDER, "RECENTLY_USED") } + runMigrations(filesDir, sharedPrefs, gitSettings) + assertEquals("RECENTLY_USED", sharedPrefs.getString(PreferenceKeys.SORT_ORDER)) + } + + @Test + fun staleOpenKeystoreKeyIdIsRemoved() { + sharedPrefs.edit { putString(PreferenceKeys.SSH_OPENKEYSTORE_KEYID, "0123456789ABCDEF") } + runMigrations( + filesDir, + sharedPrefs, + GitSettings(sharedPrefs, encryptedSharedPreferences, proxySharedPreferences, filesDir), + ) + assertFalse(sharedPrefs.contains(PreferenceKeys.SSH_OPENKEYSTORE_KEYID)) + } } diff --git a/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt b/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt new file mode 100644 index 00000000..34348692 --- /dev/null +++ b/app/src/test/java/app/passwordstore/util/settings/PasswordSortOrderTest.kt @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.util.settings + +import androidx.core.content.edit +import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder +import kotlin.test.Test +import kotlin.test.assertEquals + +class PasswordSortOrderTest { + + @Test + fun unknownAndRemovedValuesReadAsFoldersFirst() { + val prefs = SPMockBuilder().createSharedPreferences() + assertEquals(PasswordSortOrder.FOLDER_FIRST, PasswordSortOrder.getSortOrder(prefs)) + for (value in listOf("FILE_FIRST", "INDEPENDENT", "bogus")) { + prefs.edit { putString(PreferenceKeys.SORT_ORDER, value) } + assertEquals(PasswordSortOrder.FOLDER_FIRST, PasswordSortOrder.getSortOrder(prefs)) + } + prefs.edit { putString(PreferenceKeys.SORT_ORDER, "RECENTLY_USED") } + assertEquals(PasswordSortOrder.RECENTLY_USED, PasswordSortOrder.getSortOrder(prefs)) + } +} -- cgit v1.2.3 From f5a736755a151be6753a2f939c493a7f9ad5bf06 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 4 Nov 2026 11:22:03 +0100 Subject: feat(app): add the autofill picker's row model autofillRows turns the picker's search results into list rows: the origin identifier, the part that must match the site or app, becomes the row's name, and the account part and the folders above it the subtitle, as the old picker's bold title and subtitle showed them. Co-Authored-By: Claude Opus 5.5 --- .../app/passwordstore/ui/autofill/AutofillModel.kt | 38 +++++++++++ .../passwordstore/ui/autofill/AutofillModelTest.kt | 73 ++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt create mode 100644 app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt (limited to 'app/src/test/java') diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt new file mode 100644 index 00000000..44030d73 --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt @@ -0,0 +1,38 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.autofill + +import app.passwordstore.ui.list.ListRow +import app.passwordstore.util.settings.DirectoryStructure +import java.io.File +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +/** + * The autofill picker's rows for [files] (entries under [root], already ranked), in their order. + * The name is the origin identifier per [structure], the part that must match the site or app, so + * it stands out as the old picker's bold part did; the subtitle holds the account part and the + * folders above the identifier. Without an identifier the account part is the name. + */ +fun autofillRows( + files: List, + root: File, + structure: DirectoryStructure, +): ImmutableList = + files + .map { file -> + val relative = file.relativeTo(root) + val identifier = structure.getIdentifierFor(relative) + val account = structure.getAccountPartFor(relative) + val subtitle = + if (identifier == null) null + else + listOfNotNull(account, structure.getPathToIdentifierFor(relative)) + .joinToString(" · ") + .ifEmpty { null } + ListRow(file.absolutePath, identifier ?: account.orEmpty(), subtitle, 0) + } + .toImmutableList() diff --git a/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt b/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt new file mode 100644 index 00000000..8907aa54 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.autofill + +import app.passwordstore.util.settings.DirectoryStructure +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals + +class AutofillModelTest { + + private val root = File("/store") + + private fun rows(structure: DirectoryStructure, vararg paths: String) = + autofillRows(paths.map { File(root, it) }, root, structure).map { + Triple(it.path, it.label, it.subtitle) + } + + @Test + fun fileBasedNamesTheIdentifier() { + assertEquals( + listOf( + Triple("/store/work/example.org/john@doe.org.gpg", "example.org", "john@doe.org · work"), + Triple("/store/example.org/john.gpg", "example.org", "john"), + Triple("/store/example.org.gpg", "example.org", null), + ), + rows( + DirectoryStructure.FileBased, + "work/example.org/john@doe.org.gpg", + "example.org/john.gpg", + "example.org.gpg", + ), + ) + } + + @Test + fun directoryBasedFallsBackToTheAccountPart() { + assertEquals( + listOf( + Triple( + "/store/example.org/john@doe.org/password.gpg", + "example.org", + "john@doe.org/password", + ), + Triple("/store/Temporary PIN.gpg", "Temporary PIN", null), + ), + rows( + DirectoryStructure.DirectoryBased, + "example.org/john@doe.org/password.gpg", + "Temporary PIN.gpg", + ), + ) + } + + @Test + fun encryptedUsernameShowsTheFolders() { + assertEquals( + listOf(Triple("/store/work/example.org.gpg", "example.org", "work")), + rows(DirectoryStructure.EncryptedUsername, "work/example.org.gpg"), + ) + } + + @Test + fun rowsKeepTheRankingOrder() { + assertEquals( + listOf("b.org", "a.org"), + rows(DirectoryStructure.FileBased, "b.org.gpg", "a.org.gpg").map { it.second }, + ) + } +} -- cgit v1.2.3 From 1d564b91ecb1b52c585e21f10596e80bc69292d4 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 4 Nov 2026 11:28:40 +0100 Subject: feat(app): move the autofill decryption prompts to Compose AutofillDecryptActivity shows the no keys dialog and the entry view's passphrase dialog in Compose over its transparent FLAG_SECURE window. The flow starts once, on the first window focus, instead of on every onStart: Android 14 evicts a screen lock prompt shown before the activity is on top, and a restart showed a second passphrase dialog. After a key import it restarts on the next focus. Dismissing the no keys dialog now finishes instead of leaving an invisible window. Rotation and resizing no longer recreate the activity; other recreations cancel the fill. The passphrase is now cached only after a successful screen lock prompt, as the entry view does: a passphrase typed after a cancelled or unavailable prompt was cached whenever the cache feature was on. PasswordDialog, its layout, dimens.xml and requireKeysExist lose their last user and go. Co-Authored-By: Claude Opus 5.5 --- app/src/main/AndroidManifest.xml | 3 + .../ui/autofill/AutofillDecryptActivity.kt | 198 ++++++++++++++------- .../app/passwordstore/ui/autofill/AutofillModel.kt | 9 + .../app/passwordstore/ui/crypto/BasePGPActivity.kt | 44 ----- .../app/passwordstore/ui/crypto/PasswordDialog.kt | 105 ----------- .../app/passwordstore/ui/entry/EntryDestination.kt | 7 +- app/src/main/res/layout/dialog_password_entry.xml | 35 ---- app/src/main/res/values/dimens.xml | 9 - .../passwordstore/ui/autofill/AutofillModelTest.kt | 18 ++ 9 files changed, 166 insertions(+), 262 deletions(-) delete mode 100644 app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt delete mode 100644 app/src/main/res/layout/dialog_password_entry.xml delete mode 100644 app/src/main/res/values/dimens.xml (limited to 'app/src/test/java') diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bcddf4c3..15d6447e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -83,8 +83,11 @@ android:resource="@xml/oreo_autofill_service" /> + ?>(null) + + // The import's activity is still on top when its result arrives: the flow restarts on the next + // window focus, so the screen lock prompt is not shown (and evicted) behind it. + private val keyImport = + registerForActivityResult(StartActivityForResult()) { result -> + if (result.resultCode == RESULT_OK) started = false else finish() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // ponytail: a recreation (theme or locale change; rotation and resizing do not recreate, see + // the manifest) cancels the fill instead of resuming a half-done flow; the user taps the field + // again. Keep the flow's state in a ViewModel if this ever matters. + if (savedInstanceState != null) { + finish() + return + } + filePath = intent?.getStringExtra(EXTRA_FILE_PATH) ?: run { logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_FILE_PATH" } finish() return } - val clientState = + clientState = intent?.getBundleExtra(AutofillManager.EXTRA_CLIENT_STATE) ?: run { logcat(ERROR) { "AutofillDecryptActivity started without EXTRA_CLIENT_STATE" } finish() return } - val isSearchAction = intent?.getBooleanExtra(EXTRA_SEARCH_ACTION, true)!! - val action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match + val isSearchAction = intent.getBooleanExtra(EXTRA_SEARCH_ACTION, true) + action = if (isSearchAction) AutofillAction.Search else AutofillAction.Match directoryStructure = AutofillPreferences.directoryStructure(this) logcat { action.toString() } - requireKeysExist { - if ( - features.isEnabled(EnablePGPPassphraseCache) && BiometricAuthenticator.canAuthenticate(this) - ) { - BiometricAuthenticator.authenticate( - this, - R.string.biometric_prompt_title_gpg_passphrase_cache, - ) { authResult -> - decrypt(filePath, clientState, action, authResult) - } - } else { - decrypt(filePath, clientState, action, BiometricResult.CanceledByUser) + setContent { + APSTheme { + if (noKeys) + AlertDialog( + onDismissRequest = { finish() }, + title = { Text(stringResource(R.string.no_keys_imported_dialog_title)) }, + text = { Text(stringResource(R.string.no_keys_imported_dialog_message)) }, + confirmButton = { + TextButton( + onClick = { + noKeys = false + keyImport.launch( + MainActivity.setupIntent(this@AutofillDecryptActivity, SetupStart.PgpImport) + ) + } + ) { + Text(stringResource(R.string.button_label_import)) + } + }, + ) + val identifiers = passphraseFor + if (identifiers != null) + PassphraseDialog( + wrong = false, + cacheEnabled = features.isEnabled(EnablePGPPassphraseCache), + clearCache = clearCache, + onSubmit = onSubmit@{ passphrase, clear -> + // Cleared before decrypting: a second submit in the same frame does nothing. + if (passphraseFor == null) return@onSubmit + passphraseFor = null + clearCache = clear + lifecycleScope.launch(dispatcherProvider.main()) { + decryptWithPassphrase(identifiers, passphrase) + } + }, + onCancel = { finish() }, + ) } } } - private fun decrypt( - filePath: String, - clientState: Bundle, - action: AutofillAction, - authResult: BiometricResult, - ) { + // The flow waits for window focus, as LaunchActivity's prompt does: Android 14's SystemUI + // dismisses a screen lock prompt shown before its activity is on top. It runs once (again only + // after a key import); the old onStart start ran it on every return to this activity. + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (!hasFocus || started || isFinishing) return + started = true + start() + } + + /** Asks for a key import first while no PGP key is imported. */ + private fun start() { + if (isFinishing) return + lifecycleScope.launch { if (repository.hasKeys()) unlock() else noKeys = true } + } + + private fun unlock() { + if ( + features.isEnabled(EnablePGPPassphraseCache) && BiometricAuthenticator.canAuthenticate(this) + ) { + BiometricAuthenticator.authenticate( + this, + R.string.biometric_prompt_title_gpg_passphrase_cache, + ) { authResult -> + decrypt(authResult) + } + } else { + decrypt(BiometricResult.CanceledByUser) + } + } + + private fun decrypt(authResult: BiometricResult) { + auth = authResult val gpgIdentifiers = getPGPIdentifiers( getParentPath(filePath, PasswordRepository.getRepositoryDirectory().toString()) @@ -112,8 +208,7 @@ class AutofillDecryptActivity : BasePGPActivity() { is BiometricResult.CanceledBySystem, is BiometricResult.CanceledByUser, is BiometricResult.Failure, - is BiometricResult.HardwareUnavailableOrDisabled -> - askPassphrase(filePath, gpgIdentifiers, clientState, action) + is BiometricResult.HardwareUnavailableOrDisabled -> askPassphrase(gpgIdentifiers) is BiometricResult.Success -> { /* clear passphrase cache on first use after application startup or if screen was off; also make sure to purge a stale cache after caching has been disabled via PGP settings */ @@ -128,56 +223,25 @@ class AutofillDecryptActivity : BasePGPActivity() { gpgIdentifiers.first(), ) if (cachedPassphrase != null) { - decryptWithPassphrase( - File(filePath), - gpgIdentifiers, - clientState, - action, - cachedPassphrase, - ) + decryptWithPassphrase(gpgIdentifiers, cachedPassphrase) } else { - askPassphrase(filePath, gpgIdentifiers, clientState, action) + askPassphrase(gpgIdentifiers) } } } } } - private suspend fun askPassphrase( - filePath: String, - identifiers: List, - clientState: Bundle, - action: AutofillAction, - ) { + private suspend fun askPassphrase(identifiers: List) { if (!repository.isPasswordProtected(identifiers)) { - decryptWithPassphrase(File(filePath), identifiers, clientState, action, password = "") + decryptWithPassphrase(identifiers, password = "") return } - val dialog = - PasswordDialog.newInstance( - cacheEnabled = features.isEnabled(EnablePGPPassphraseCache), - clearCache = clearCache, - ) - dialog.show(supportFragmentManager, "PASSWORD_DIALOG") - dialog.setFragmentResultListener(PasswordDialog.PASSWORD_RESULT_KEY) { key, bundle -> - if (key == PasswordDialog.PASSWORD_RESULT_KEY) { - val value = bundle.getString(PasswordDialog.PASSWORD_PHRASE_KEY)!! - clearCache = bundle.getBoolean(PasswordDialog.PASSWORD_CLEAR_KEY) - lifecycleScope.launch(dispatcherProvider.main()) { - decryptWithPassphrase(File(filePath), identifiers, clientState, action, value) - } - } - } + passphraseFor = identifiers } - private suspend fun decryptWithPassphrase( - filePath: File, - identifiers: List, - clientState: Bundle, - action: AutofillAction, - password: String, - ) { - val credentials = decryptCredential(filePath, password, identifiers) + private suspend fun decryptWithPassphrase(identifiers: List, password: String) { + val credentials = decryptCredential(File(filePath), password, identifiers) if (credentials == null) { setResult(RESULT_CANCELED) } else { @@ -219,7 +283,7 @@ class AutofillDecryptActivity : BasePGPActivity() { .onSuccess { result -> return runCatching { runCatching { - if (features.isEnabled(EnablePGPPassphraseCache)) { + if (cachesPassphrase(features.isEnabled(EnablePGPPassphraseCache), auth)) { passphraseCache.cachePassphrase(this, identifiers.first(), password) settings.edit { putBoolean(PreferenceKeys.CLEAR_PASSPHRASE_CACHE, clearCache) diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt index ac2d0948..9f436ea2 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillModel.kt @@ -6,6 +6,7 @@ package app.passwordstore.ui.autofill import app.passwordstore.ui.list.ListRow +import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult import app.passwordstore.util.settings.DirectoryStructure import java.io.File import kotlinx.collections.immutable.ImmutableList @@ -36,3 +37,11 @@ fun autofillRows( ListRow(file.absolutePath, identifier ?: account ?: file.nameWithoutExtension, subtitle, 0) } .toImmutableList() + +/** + * Whether autofill may cache the passphrase it just decrypted with: only with the cache feature on + * ([cacheEnabled]) and after a successful screen lock prompt ([auth]), as the entry view does. A + * passphrase typed after a cancelled, failed or unavailable prompt is never kept. + */ +fun cachesPassphrase(cacheEnabled: Boolean, auth: BiometricResult): Boolean = + cacheEnabled && auth is BiometricResult.Success diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt index 6831761b..e40585d5 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt @@ -12,19 +12,15 @@ import android.os.Build import android.os.Bundle import android.os.PersistableBundle import android.view.WindowManager -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.annotation.CallSuper import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.lifecycleScope import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier import app.passwordstore.data.crypto.CryptoRepository import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.injection.prefs.SettingsPreferences -import app.passwordstore.ui.main.MainActivity -import app.passwordstore.ui.setup.SetupStart import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.clipboard import app.passwordstore.util.extensions.getString @@ -33,29 +29,14 @@ import app.passwordstore.util.extensions.snackbar import app.passwordstore.util.services.ClipboardService import app.passwordstore.util.settings.Constants import app.passwordstore.util.settings.PreferenceKeys -import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import java.io.File import javax.inject.Inject -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext @Suppress("Registered") @AndroidEntryPoint open class BasePGPActivity : AppCompatActivity() { - /** Action to invoke if [keyImportAction] succeeds. */ - private var onKeyImport: (() -> Unit)? = null - private val keyImportAction = - registerForActivityResult(StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - onKeyImport?.invoke() - onKeyImport = null - } else { - finish() - } - } - /** [SharedPreferences] instance used by subclasses to persist settings */ @SettingsPreferences @Inject lateinit var settings: SharedPreferences @Inject lateinit var repository: CryptoRepository @@ -71,31 +52,6 @@ open class BasePGPActivity : AppCompatActivity() { window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) } - /** - * Function to execute [onKeysExist] only if there are PGP keys imported in the app's key manager. - */ - fun requireKeysExist(onKeysExist: () -> Unit) { - lifecycleScope.launch { - val hasKeys = repository.hasKeys() - if (!hasKeys) { - withContext(dispatcherProvider.main()) { - MaterialAlertDialogBuilder(this@BasePGPActivity) - .setTitle(resources.getString(R.string.no_keys_imported_dialog_title)) - .setMessage(resources.getString(R.string.no_keys_imported_dialog_message)) - .setPositiveButton(resources.getString(R.string.button_label_import)) { _, _ -> - onKeyImport = onKeysExist - keyImportAction.launch( - MainActivity.setupIntent(this@BasePGPActivity, SetupStart.PgpImport) - ) - } - .show() - } - } else { - onKeysExist() - } - } - } - companion object { const val EXTRA_FILE_PATH = "FILE_PATH" diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt deleted file mode 100644 index d845d689..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordDialog.kt +++ /dev/null @@ -1,105 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import android.app.Dialog -import android.content.DialogInterface -import android.os.Bundle -import android.view.KeyEvent -import android.view.WindowManager -import androidx.core.os.bundleOf -import androidx.core.view.isVisible -import androidx.core.widget.doOnTextChanged -import androidx.fragment.app.DialogFragment -import androidx.fragment.app.setFragmentResult -import app.passwordstore.R -import app.passwordstore.databinding.DialogPasswordEntryBinding -import app.passwordstore.util.extensions.finish -import app.passwordstore.util.extensions.unsafeLazy -import com.google.android.material.dialog.MaterialAlertDialogBuilder - -/** [DialogFragment] to request a password from the user and forward it along. */ -class PasswordDialog : DialogFragment() { - - private val binding by unsafeLazy { DialogPasswordEntryBinding.inflate(layoutInflater) } - private var isError: Boolean = false - private var clearCacheChecked: Boolean = true - - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val builder = MaterialAlertDialogBuilder(requireContext()) - builder.setView(binding.root) - builder.setTitle(R.string.password) - - val cacheEnabled = requireArguments().getBoolean(CACHE_ENABLED_EXTRA, false) - binding.autoClearCache.isVisible = cacheEnabled - - if (cacheEnabled) { - clearCacheChecked = requireArguments().getBoolean(AUTO_CLEAR_CACHE_EXTRA) - binding.autoClearCache.isChecked = clearCacheChecked - binding.autoClearCache.setOnCheckedChangeListener { _, isChecked -> - clearCacheChecked = isChecked - } - } - - builder.setPositiveButton(android.R.string.ok) { _, _ -> setPasswordAndDismiss() } - val dialog = builder.create() - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE) - dialog.setOnShowListener { - if (isError) { - binding.passwordField.error = getString(R.string.git_operation_wrong_password) - } - binding.passwordEditText.doOnTextChanged { _, _, _, _ -> binding.passwordField.error = null } - binding.passwordEditText.setOnKeyListener { _, keyCode, _ -> - if (keyCode == KeyEvent.KEYCODE_ENTER) { - setPasswordAndDismiss() - return@setOnKeyListener true - } - false - } - } - dialog.window?.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE, - ) - return dialog - } - - fun setError() { - isError = true - } - - override fun onCancel(dialog: DialogInterface) { - super.onCancel(dialog) - finish() - } - - private fun setPasswordAndDismiss() { - val password = binding.passwordEditText.text.toString() - setFragmentResult( - PASSWORD_RESULT_KEY, - bundleOf(PASSWORD_PHRASE_KEY to password, PASSWORD_CLEAR_KEY to clearCacheChecked), - ) - dismissAllowingStateLoss() - } - - companion object { - - private const val CACHE_ENABLED_EXTRA = "CACHE_ENABLED" - private const val AUTO_CLEAR_CACHE_EXTRA = "AUTO_CLEAR_CACHE" - - const val PASSWORD_RESULT_KEY = "password_result" - const val PASSWORD_PHRASE_KEY = "password_phrase" - const val PASSWORD_CLEAR_KEY = "password_clear" - - fun newInstance(cacheEnabled: Boolean, clearCache: Boolean): PasswordDialog { - val extras = - bundleOf(CACHE_ENABLED_EXTRA to cacheEnabled, AUTO_CLEAR_CACHE_EXTRA to clearCache) - val fragment = PasswordDialog() - fragment.arguments = extras - return fragment - } - } -} diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt index 637c9b56..e5341320 100644 --- a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt @@ -173,9 +173,12 @@ fun EntryDestination( } } -/** Asks for the PGP passphrase, as the old `PasswordDialog` did. */ +/** + * Asks for the PGP passphrase (entry view and autofill). The passphrase lives only in `remember` + * and the dialog's window is FLAG_SECURE. + */ @Composable -private fun PassphraseDialog( +internal fun PassphraseDialog( wrong: Boolean, cacheEnabled: Boolean, clearCache: Boolean, diff --git a/app/src/main/res/layout/dialog_password_entry.xml b/app/src/main/res/layout/dialog_password_entry.xml deleted file mode 100644 index bbfbc506..00000000 --- a/app/src/main/res/layout/dialog_password_entry.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - - diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100644 index de2ac854..00000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - 16dp - diff --git a/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt b/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt index 8907aa54..39f7f358 100644 --- a/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt +++ b/app/src/test/java/app/passwordstore/ui/autofill/AutofillModelTest.kt @@ -5,10 +5,13 @@ package app.passwordstore.ui.autofill +import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult import app.passwordstore.util.settings.DirectoryStructure import java.io.File import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class AutofillModelTest { @@ -70,4 +73,19 @@ class AutofillModelTest { rows(DirectoryStructure.FileBased, "b.org.gpg", "a.org.gpg").map { it.second }, ) } + + @Test + fun passphraseIsCachedOnlyAfterTheScreenLock() { + assertTrue(cachesPassphrase(true, BiometricResult.Success(null))) + val notConfirmed = + listOf( + BiometricResult.CanceledByUser, + BiometricResult.CanceledBySystem, + BiometricResult.HardwareUnavailableOrDisabled, + BiometricResult.Failure(null, "failed"), + BiometricResult.Retry, + ) + for (auth in notConfirmed) assertFalse(cachesPassphrase(true, auth), auth.toString()) + assertFalse(cachesPassphrase(false, BiometricResult.Success(null))) + } } -- cgit v1.2.3 From 104c07f0bcc37d87ddc371d06827ff93ee3fe504 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Wed, 4 Nov 2026 17:37:46 +0100 Subject: refactor(app): fold BasePGPActivity into the autofill decryption AutofillDecryptActivity was its last subclass: it now sets FLAG_SECURE and injects the settings, crypto repository and dispatchers itself. The path and clipboard helpers move to CryptoExtensions.kt as top-level functions (a test pins the path helpers), and the FILE_PATH extra key moves, unchanged, to LaunchActivity. An invalid or short .gpg-id ID is reported with a toast: the snackbar never showed in the autofill decryption, which closes its window at once. Co-Authored-By: Claude Opus 5.5 --- .../passwordstore/data/password/PasswordItem.kt | 6 +- .../ui/autofill/AutofillDecryptActivity.kt | 16 +- .../app/passwordstore/ui/crypto/BasePGPActivity.kt | 181 --------------------- .../passwordstore/ui/crypto/CryptoExtensions.kt | 149 +++++++++++++++++ .../app/passwordstore/ui/entry/EntryDestination.kt | 2 +- .../java/app/passwordstore/ui/list/StoreActions.kt | 2 +- .../app/passwordstore/ui/main/LaunchActivity.kt | 8 +- .../java/app/passwordstore/ui/main/MainActivity.kt | 3 +- .../ui/crypto/CryptoExtensionsTest.kt | 27 +++ 9 files changed, 201 insertions(+), 193 deletions(-) delete mode 100644 app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt create mode 100644 app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt create mode 100644 app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt (limited to 'app/src/test/java') diff --git a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt index 999ad050..5d32108f 100644 --- a/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt +++ b/app/src/main/java/app/passwordstore/data/password/PasswordItem.kt @@ -6,7 +6,7 @@ package app.passwordstore.data.password import android.content.Context import android.content.Intent -import app.passwordstore.ui.crypto.BasePGPActivity +import app.passwordstore.ui.crypto.getLongName import app.passwordstore.ui.main.LaunchActivity import java.io.File @@ -15,7 +15,7 @@ data class PasswordItem(val name: String, val type: Char, val file: File, val ro val fullPathToParent = file.absolutePath.replace(rootDir.absolutePath, "").replace(file.name, "") - val longName = BasePGPActivity.getLongName(fullPathToParent, rootDir.absolutePath, toString()) + val longName = getLongName(fullPathToParent, rootDir.absolutePath, toString()) override fun equals(other: Any?): Boolean { return (other is PasswordItem) && (other.file == file) @@ -36,7 +36,7 @@ data class PasswordItem(val name: String, val type: Char, val file: File, val ro /** Creates an [Intent] to launch this [PasswordItem] through the authentication process. */ fun createAuthEnabledIntent(context: Context): Intent { val intent = Intent(context, LaunchActivity::class.java) - intent.putExtra("FILE_PATH", file.absolutePath) + intent.putExtra(LaunchActivity.EXTRA_FILE_PATH, file.absolutePath) intent.action = LaunchActivity.ACTION_DECRYPT_PASS return intent } diff --git a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt index 39bf8cff..3f0fb5ef 100644 --- a/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/autofill/AutofillDecryptActivity.kt @@ -8,11 +8,14 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.IntentSender +import android.content.SharedPreferences import android.os.Build import android.os.Bundle +import android.view.WindowManager import android.view.autofill.AutofillManager import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.AlertDialog import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -25,12 +28,14 @@ import androidx.lifecycle.lifecycleScope import app.passwordstore.Application.Companion.screenWasOff import app.passwordstore.R import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.data.crypto.CryptoRepository import app.passwordstore.data.crypto.PGPPassphraseCache import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.injection.prefs.SettingsPreferences import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.crypto.BasePGPActivity import app.passwordstore.ui.crypto.getPGPIdentifiers +import app.passwordstore.ui.crypto.getParentPath import app.passwordstore.ui.entry.PassphraseDialog import app.passwordstore.ui.main.MainActivity import app.passwordstore.ui.setup.SetupStart @@ -38,6 +43,7 @@ import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result as BiometricResult import app.passwordstore.util.autofill.AutofillPreferences import app.passwordstore.util.autofill.AutofillResponseBuilder +import app.passwordstore.util.coroutines.DispatcherProvider import app.passwordstore.util.extensions.asLog import app.passwordstore.util.features.Feature.EnablePGPPassphraseCache import app.passwordstore.util.features.Features @@ -64,8 +70,11 @@ import logcat.logcat * FLAG_SECURE window shows only the "no keys" and passphrase dialogs. */ @AndroidEntryPoint -class AutofillDecryptActivity : BasePGPActivity() { +class AutofillDecryptActivity : AppCompatActivity() { + @SettingsPreferences @Inject lateinit var settings: SharedPreferences + @Inject lateinit var repository: CryptoRepository + @Inject lateinit var dispatcherProvider: DispatcherProvider @Inject lateinit var passwordEntryFactory: PasswordEntry.Factory @Inject lateinit var features: Features @Inject lateinit var passphraseCache: PGPPassphraseCache @@ -97,6 +106,9 @@ class AutofillDecryptActivity : BasePGPActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + // Before anything is drawn: the passphrase dialog's parent stays out of screenshots and + // recents. + window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) // ponytail: a recreation (theme or locale change; rotation and resizing do not recreate, see // the manifest) cancels the fill instead of resuming a half-done flow; the user taps the field // again. Keep the flow's state in a ViewModel if this ever matters. diff --git a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt deleted file mode 100644 index e40585d5..00000000 --- a/app/src/main/java/app/passwordstore/ui/crypto/BasePGPActivity.kt +++ /dev/null @@ -1,181 +0,0 @@ -/* - * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. - * SPDX-License-Identifier: GPL-3.0-only - */ - -package app.passwordstore.ui.crypto - -import android.content.ClipData -import android.content.Intent -import android.content.SharedPreferences -import android.os.Build -import android.os.Bundle -import android.os.PersistableBundle -import android.view.WindowManager -import androidx.annotation.CallSuper -import androidx.annotation.StringRes -import androidx.appcompat.app.AppCompatActivity -import androidx.fragment.app.FragmentActivity -import app.passwordstore.R -import app.passwordstore.crypto.PGPIdentifier -import app.passwordstore.data.crypto.CryptoRepository -import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.injection.prefs.SettingsPreferences -import app.passwordstore.util.coroutines.DispatcherProvider -import app.passwordstore.util.extensions.clipboard -import app.passwordstore.util.extensions.getString -import app.passwordstore.util.extensions.sharedPrefs -import app.passwordstore.util.extensions.snackbar -import app.passwordstore.util.services.ClipboardService -import app.passwordstore.util.settings.Constants -import app.passwordstore.util.settings.PreferenceKeys -import dagger.hilt.android.AndroidEntryPoint -import java.io.File -import javax.inject.Inject - -@Suppress("Registered") -@AndroidEntryPoint -open class BasePGPActivity : AppCompatActivity() { - - /** [SharedPreferences] instance used by subclasses to persist settings */ - @SettingsPreferences @Inject lateinit var settings: SharedPreferences - @Inject lateinit var repository: CryptoRepository - @Inject lateinit var dispatcherProvider: DispatcherProvider - - /** - * [onCreate] sets the window up with the right flags to prevent auth leaks through screenshots or - * recent apps screen. - */ - @CallSuper - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - window.setFlags(WindowManager.LayoutParams.FLAG_SECURE, WindowManager.LayoutParams.FLAG_SECURE) - } - - companion object { - - const val EXTRA_FILE_PATH = "FILE_PATH" - - /** Gets the relative path to the repository */ - fun getRelativePath(fullPath: String, repositoryPath: String): String = - fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") - - /** Gets the Parent path, relative to the repository */ - fun getParentPath(fullPath: String, repositoryPath: String): String { - val relativePath = getRelativePath(fullPath, repositoryPath) - val index = relativePath.lastIndexOf("/") - return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/" - .replace("/+".toRegex(), "/") - } - - /** /path/to/store/social/facebook.gpg -> social/facebook */ - @JvmStatic - fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { - var relativePath = getRelativePath(fullPath, repositoryPath) - return if (relativePath.isNotEmpty() && relativePath != "/") { - // remove preceding '/' - relativePath = relativePath.substring(1) - if (relativePath.endsWith('/')) { - relativePath + basename - } else { - "$relativePath/$basename" - } - } else { - basename - } - } - } -} - -/** - * Copies provided [text] to the clipboard, flagged sensitive. Below Android 11 it also shows a - * snackbar, which can be disabled by passing [showSnackbar] as false. - */ -fun FragmentActivity.copyTextToClipboard( - text: String?, - showSnackbar: Boolean = true, - @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text, -) { - val clipboard = clipboard ?: return - val clip = ClipData.newPlainText("pgp_handler_result_pm", text) - clip.description.extras = - PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) } - clipboard.setPrimaryClip(clip) - if (showSnackbar && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - snackbar(message = resources.getString(snackbarTextRes)) - } -} - -/** - * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] and starts - * off an instance of [ClipboardService] to provide a way of clearing the clipboard. - */ -fun FragmentActivity.copyPasswordToClipboard(password: String?) { - copyTextToClipboard(password) - val clearAfter = - sharedPrefs.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() - ?: Constants.DEFAULT_DECRYPTION_TIMEOUT - - if (clearAfter != 0) { - val service = - Intent(this, ClipboardService::class.java).apply { - action = ClipboardService.ACTION_START - putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) - } - startForegroundService(service) - } -} - -/** - * Get a list of available [PGPIdentifier]s for the current password repository. This method throws - * when no identifiers were able to be parsed. If this method returns null, it means that an invalid - * identifier was encountered and further execution must stop to let the user correct the problem. - */ -fun FragmentActivity.getPGPIdentifiers(subDir: String): List? { - val repoRoot = PasswordRepository.getRepositoryDirectory() - val gpgIdentifierFile = - File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot) - ?: File(repoRoot, ".gpg-id").apply { createNewFile() } - val gpgIdentifiers = - gpgIdentifierFile - .readLines() - .filter { it.isNotBlank() } - .map { line -> - PGPIdentifier.fromString(line) - ?: run { - // The line being empty means this is most likely an empty `.gpg-id` - // file we created. Skip the validation so we can make the user add a - // real ID. - if (line.isEmpty()) return@run - // Apparently `gpg-id` being the first line is also acceptable? - if (line == "gpg-id") return@run - if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) { - snackbar(message = resources.getString(R.string.short_gpg_id)) - } else { - snackbar(message = resources.getString(R.string.invalid_gpg_id)) - } - return null - } - } - .filterIsInstance() - if (gpgIdentifiers.isEmpty()) { - error("Failed to parse identifiers from .gpg-id: ${gpgIdentifierFile.readText()}") - } - return gpgIdentifiers -} - -@Suppress("ReturnCount") -private fun File.findTillRoot(fileName: String, rootPath: File): File? { - val gpgFile = File(this, fileName) - if (gpgFile.exists()) return gpgFile - - if (this.absolutePath == rootPath.absolutePath) { - return null - } - val parent = parentFile - return if (parent != null && parent.exists()) { - parent.findTillRoot(fileName, rootPath) - } else { - null - } -} diff --git a/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt new file mode 100644 index 00000000..fbaa534f --- /dev/null +++ b/app/src/main/java/app/passwordstore/ui/crypto/CryptoExtensions.kt @@ -0,0 +1,149 @@ +/* + * Copyright © 2014-2024 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.crypto + +import android.content.ClipData +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.PersistableBundle +import android.widget.Toast +import androidx.annotation.StringRes +import androidx.fragment.app.FragmentActivity +import app.passwordstore.R +import app.passwordstore.crypto.PGPIdentifier +import app.passwordstore.data.repo.PasswordRepository +import app.passwordstore.util.extensions.clipboard +import app.passwordstore.util.extensions.getString +import app.passwordstore.util.extensions.sharedPrefs +import app.passwordstore.util.extensions.snackbar +import app.passwordstore.util.services.ClipboardService +import app.passwordstore.util.settings.Constants +import app.passwordstore.util.settings.PreferenceKeys +import java.io.File + +/** Gets the relative path to the repository */ +private fun getRelativePath(fullPath: String, repositoryPath: String): String = + fullPath.replace(repositoryPath, "").replace("/+".toRegex(), "/") + +/** Gets the Parent path, relative to the repository */ +fun getParentPath(fullPath: String, repositoryPath: String): String { + val relativePath = getRelativePath(fullPath, repositoryPath) + val index = relativePath.lastIndexOf("/") + return "/${relativePath.substring(startIndex = 0, endIndex = index + 1)}/" + .replace("/+".toRegex(), "/") +} + +/** /path/to/store/social/facebook.gpg -> social/facebook */ +fun getLongName(fullPath: String, repositoryPath: String, basename: String): String { + var relativePath = getRelativePath(fullPath, repositoryPath) + return if (relativePath.isNotEmpty() && relativePath != "/") { + // remove preceding '/' + relativePath = relativePath.substring(1) + if (relativePath.endsWith('/')) { + relativePath + basename + } else { + "$relativePath/$basename" + } + } else { + basename + } +} + +/** + * Copies provided [text] to the clipboard, flagged sensitive. Below Android 11 it also shows a + * snackbar, which can be disabled by passing [showSnackbar] as false. + */ +fun FragmentActivity.copyTextToClipboard( + text: String?, + showSnackbar: Boolean = true, + @StringRes snackbarTextRes: Int = R.string.clipboard_copied_text, +) { + val clipboard = clipboard ?: return + val clip = ClipData.newPlainText("pgp_handler_result_pm", text) + clip.description.extras = + PersistableBundle().apply { putBoolean("android.content.extra.IS_SENSITIVE", true) } + clipboard.setPrimaryClip(clip) + if (showSnackbar && Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + snackbar(message = resources.getString(snackbarTextRes)) + } +} + +/** + * Copies a provided [password] string to the clipboard. This wraps [copyTextToClipboard] and starts + * off an instance of [ClipboardService] to provide a way of clearing the clipboard. + */ +fun FragmentActivity.copyPasswordToClipboard(password: String?) { + copyTextToClipboard(password) + val clearAfter = + sharedPrefs.getString(PreferenceKeys.GENERAL_SHOW_TIME)?.toIntOrNull() + ?: Constants.DEFAULT_DECRYPTION_TIMEOUT + + if (clearAfter != 0) { + val service = + Intent(this, ClipboardService::class.java).apply { + action = ClipboardService.ACTION_START + putExtra(ClipboardService.EXTRA_NOTIFICATION_TIME, clearAfter) + } + startForegroundService(service) + } +} + +/** + * Get a list of available [PGPIdentifier]s for the current password repository. This method throws + * when no identifiers were able to be parsed. If this method returns null, an invalid identifier + * was encountered and reported with a toast: further execution must stop to let the user correct + * the problem. + */ +fun Context.getPGPIdentifiers(subDir: String): List? { + val repoRoot = PasswordRepository.getRepositoryDirectory() + val gpgIdentifierFile = + File(repoRoot, subDir).findTillRoot(".gpg-id", repoRoot) + ?: File(repoRoot, ".gpg-id").apply { createNewFile() } + val gpgIdentifiers = + gpgIdentifierFile + .readLines() + .filter { it.isNotBlank() } + .map { line -> + PGPIdentifier.fromString(line) + ?: run { + // The line being empty means this is most likely an empty `.gpg-id` + // file we created. Skip the validation so we can make the user add a + // real ID. + if (line.isEmpty()) return@run + // Apparently `gpg-id` being the first line is also acceptable? + if (line == "gpg-id") return@run + val message = + if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex())) R.string.short_gpg_id + else R.string.invalid_gpg_id + // A toast on the application context, not a snackbar: the autofill decryption + // finishes its window right after, and the toast must outlive it. + Toast.makeText(applicationContext, message, Toast.LENGTH_LONG).show() + return null + } + } + .filterIsInstance() + if (gpgIdentifiers.isEmpty()) { + error("Failed to parse identifiers from .gpg-id: ${gpgIdentifierFile.readText()}") + } + return gpgIdentifiers +} + +@Suppress("ReturnCount") +private fun File.findTillRoot(fileName: String, rootPath: File): File? { + val gpgFile = File(this, fileName) + if (gpgFile.exists()) return gpgFile + + if (this.absolutePath == rootPath.absolutePath) { + return null + } + val parent = parentFile + return if (parent != null && parent.exists()) { + parent.findTillRoot(fileName, rootPath) + } else { + null + } +} diff --git a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt index e5341320..02b112f4 100644 --- a/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt +++ b/app/src/main/java/app/passwordstore/ui/entry/EntryDestination.kt @@ -43,10 +43,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import app.passwordstore.R import app.passwordstore.data.passfile.Totp import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getParentPath import app.passwordstore.ui.crypto.copyPasswordToClipboard import app.passwordstore.ui.crypto.copyTextToClipboard import app.passwordstore.ui.crypto.getPGPIdentifiers +import app.passwordstore.ui.crypto.getParentPath import app.passwordstore.ui.main.LaunchActivity import app.passwordstore.ui.main.MainActivity import app.passwordstore.ui.setup.SetupStart diff --git a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt index 2e148401..3bdede73 100644 --- a/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt +++ b/app/src/main/java/app/passwordstore/ui/list/StoreActions.kt @@ -9,7 +9,7 @@ import android.content.Context import androidx.core.content.edit import app.passwordstore.R import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity.Companion.getLongName +import app.passwordstore.ui.crypto.getLongName import app.passwordstore.ui.git.base.BaseGitActivity import app.passwordstore.util.autofill.AutofillMatcher import app.passwordstore.util.extensions.base64 diff --git a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt index 6f07a51a..c0af5742 100644 --- a/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/LaunchActivity.kt @@ -12,7 +12,6 @@ import android.os.Looper import androidx.appcompat.app.AppCompatActivity import androidx.core.content.edit import app.passwordstore.data.repo.PasswordRepository -import app.passwordstore.ui.crypto.BasePGPActivity import app.passwordstore.util.auth.BiometricAuthenticator import app.passwordstore.util.auth.BiometricAuthenticator.Result import app.passwordstore.util.extensions.sharedPrefs @@ -61,10 +60,10 @@ class LaunchActivity : AppCompatActivity() { // This activity is exported, so the file path is untrusted input: only forward it when it // points at a file inside the password store. MainActivity is not exported and trusts it. val repoDir = PasswordRepository.getRepositoryDirectory() - val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) + val filePath = intent.getStringExtra(EXTRA_FILE_PATH) val intentToStart = Intent(this, MainActivity::class.java) if (intent.action == ACTION_DECRYPT_PASS && isFileInRepo(filePath, repoDir)) - intentToStart.putExtra(BasePGPActivity.EXTRA_FILE_PATH, filePath) + intentToStart.putExtra(EXTRA_FILE_PATH, filePath) // A plain start replaces the task, so an instance already open (one whose store vanished, say) // is not left below the new one. An entry opens on top, back returns to where the user was. else intentToStart.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) @@ -77,6 +76,9 @@ class LaunchActivity : AppCompatActivity() { const val ACTION_DECRYPT_PASS = "DECRYPT_PASS" + /** The entry to open; home screen shortcuts carry this exact key, never change it. */ + const val EXTRA_FILE_PATH = "FILE_PATH" + /** True when [path] resolves, after following `..` and symlinks, to a file under [repoDir]. */ fun isFileInRepo(path: String?, repoDir: File): Boolean { if (path.isNullOrEmpty()) return false diff --git a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt index a381ca84..ce5fb1df 100644 --- a/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/main/MainActivity.kt @@ -21,7 +21,6 @@ import androidx.navigation.toRoute import app.passwordstore.data.password.PasswordItem import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.ui.compose.theme.APSTheme -import app.passwordstore.ui.crypto.BasePGPActivity import app.passwordstore.ui.editor.EditorDestination import app.passwordstore.ui.editor.EditorForm import app.passwordstore.ui.editor.editorForm @@ -84,7 +83,7 @@ class MainActivity : BaseGitActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val filePath = intent.getStringExtra(BasePGPActivity.EXTRA_FILE_PATH) + val filePath = intent.getStringExtra(LaunchActivity.EXTRA_FILE_PATH) val setup = intent.getStringExtra(EXTRA_SETUP)?.let { name -> SetupStart.entries.firstOrNull { it.name == name } diff --git a/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt b/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt new file mode 100644 index 00000000..fee6bf72 --- /dev/null +++ b/app/src/test/java/app/passwordstore/ui/crypto/CryptoExtensionsTest.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2026 Danilo M. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.ui.crypto + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Pins the path helpers' behavior across their move out of BasePGPActivity. */ +class CryptoExtensionsTest { + + @Test + fun parentPathIsRelativeToTheStore() { + assertEquals("/social/", getParentPath("/store/social/facebook.gpg", "/store")) + assertEquals("/", getParentPath("/store/example.org.gpg", "/store")) + } + + @Test + fun longNameJoinsFolderAndName() { + // StoreActions passes absolute folders, PasswordItem its path relative to the store. + assertEquals("work/x", getLongName("/store/work", "/store", "x")) + assertEquals("social/facebook", getLongName("/social/", "/store", "facebook")) + assertEquals("x", getLongName("/store", "/store", "x")) + } +} -- cgit v1.2.3