aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt7
-rw-r--r--crypto/pgpainless/src/test/kotlin/app/passwordstore/crypto/KeyUtilsTest.kt19
6 files changed, 306 insertions, 11 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)))
+ }
+}
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())))
+ }
}