diff options
Diffstat (limited to 'app/src/main/java')
| -rw-r--r-- | app/src/main/java/app/passwordstore/ui/setup/SetupModel.kt | 102 | ||||
| -rw-r--r-- | app/src/main/java/app/passwordstore/util/git/sshj/SshKey.kt | 35 |
2 files changed, 127 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) ) |
