aboutsummaryrefslogtreecommitdiffstats
path: root/app/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-11-03 13:44:42 +0100
committerDanilo M. <danix@danix.xyz>2026-11-03 13:44:42 +0100
commit6a614fe7dc1cbbec5783828d58b246a362edea07 (patch)
treea949264fdde608b789a215f63318af7bae29da96 /app/src
parent9d4b14306444f42ba8a5f11711f0f7381fd7db0f (diff)
downloadHardenedPass-6a614fe7dc1cbbec5783828d58b246a362edea07.tar.gz
HardenedPass-6a614fe7dc1cbbec5783828d58b246a362edea07.zip
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 <noreply@anthropic.com>
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt102
-rw-r--r--app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt35
-rw-r--r--app/src/test/java/app/passwordstore/ui/setup/SetupModelTest.kt121
-rw-r--r--app/src/test/java/app/passwordstore/util/git/sshj/SshKeyFileTest.kt33
4 files changed, 281 insertions, 10 deletions
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. <danix@danix.xyz>
+ * 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<AuthMode> =
+ 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<PGPIdentifier> =
+ 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 <key ID>`.
+ */
+fun gpgIdText(keys: List<PGPIdentifier.KeyId>): 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<String>): 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. <danix@danix.xyz>
+ * 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 <alice@example.org> \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.KeyId>(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. <danix@danix.xyz>
+ * 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)))
+ }
+}