diff --git a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt index 6c214fd1..297ada95 100644 --- a/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt +++ b/app/src/main/java/app/passwordstore/data/crypto/CryptoRepository.kt @@ -5,9 +5,10 @@ package app.passwordstore.data.crypto +import app.passwordstore.crypto.GpgIdentifier import app.passwordstore.crypto.PGPKeyManager import app.passwordstore.crypto.PGPainlessCryptoHandler -import app.passwordstore.util.extensions.isOk +import com.github.michaelbull.result.getAll import com.github.michaelbull.result.unwrap import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream @@ -30,8 +31,12 @@ constructor( withContext(Dispatchers.IO) { decryptPgp(password, message, out) } } - suspend fun encrypt(content: ByteArrayInputStream, out: ByteArrayOutputStream) { - withContext(Dispatchers.IO) { encryptPgp(content, out) } + suspend fun encrypt( + identities: List, + content: ByteArrayInputStream, + out: ByteArrayOutputStream, + ) { + withContext(Dispatchers.IO) { encryptPgp(identities, content, out) } } private suspend fun decryptPgp( @@ -41,11 +46,15 @@ constructor( ) { val keys = pgpKeyManager.getAllKeys().unwrap() // Iterates through the keys until the first successful decryption, then returns. - keys.firstOrNull { key -> pgpCryptoHandler.decrypt(key, password, message, out).isOk() } + pgpCryptoHandler.decrypt(keys, password, message, out) } - private suspend fun encryptPgp(content: ByteArrayInputStream, out: ByteArrayOutputStream) { - val keys = pgpKeyManager.getAllKeys().unwrap() + private suspend fun encryptPgp( + identities: List, + content: ByteArrayInputStream, + out: ByteArrayOutputStream, + ) { + val keys = identities.map { ident -> pgpKeyManager.getKeyById(ident) }.getAll() pgpCryptoHandler.encrypt( keys, content, diff --git a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt index 3fb0d52f..fa37f501 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/DecryptActivity.kt @@ -21,6 +21,7 @@ import app.passwordstore.util.extensions.unsafeLazy import app.passwordstore.util.extensions.viewBinding import app.passwordstore.util.settings.PreferenceKeys import com.github.michaelbull.result.runCatching +import com.github.michaelbull.result.unwrapError import dagger.hilt.android.AndroidEntryPoint import java.io.ByteArrayOutputStream import java.io.File @@ -35,6 +36,8 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import logcat.LogPriority.ERROR +import logcat.logcat @OptIn(ExperimentalTime::class) @AndroidEntryPoint @@ -140,7 +143,9 @@ class DecryptActivity : BasePgpActivity() { lifecycleScope.launch(Dispatchers.Main) { dialog.password.collectLatest { value -> if (value != null) { - if (runCatching { decrypt(value) }.isErr()) { + val res = runCatching { decrypt(value) } + if (res.isErr()) { + logcat(ERROR) { res.unwrapError().stackTraceToString() } decrypt(isError = true) } } @@ -161,7 +166,6 @@ class DecryptActivity : BasePgpActivity() { ) outputStream } - require(result.size() != 0) { "Incorrect password" } startAutoDismissTimer() val entry = passwordEntryFactory.create(result.toByteArray()) diff --git a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt index 2e3e48a9..fddc2943 100644 --- a/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt +++ b/app/src/main/java/app/passwordstore/ui/crypto/PasswordCreationActivity.kt @@ -24,8 +24,10 @@ import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.lifecycleScope import app.passwordstore.R +import app.passwordstore.crypto.GpgIdentifier import app.passwordstore.data.crypto.CryptoRepository import app.passwordstore.data.passfile.PasswordEntry +import app.passwordstore.data.repo.PasswordRepository import app.passwordstore.databinding.PasswordCreationActivityBinding import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment import app.passwordstore.ui.dialogs.OtpImportDialogFragment @@ -332,6 +334,32 @@ class PasswordCreationActivity : BasePgpActivity() { copyPasswordToClipboard(editPass) } + // pass enters the key ID into `.gpg-id`. + val repoRoot = PasswordRepository.getRepositoryDirectory() + val gpgIdentifierFile = + File(repoRoot, directory.text.toString()).findTillRoot(".gpg-id", repoRoot) + ?: File(repoRoot, ".gpg-id").apply { createNewFile() } + val gpgIdentifiers = + gpgIdentifierFile + .readLines() + .filter { it.isNotBlank() } + .map { line -> + GpgIdentifier.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 + if (line.removePrefix("0x").matches("[a-fA-F0-9]{8}".toRegex()).not()) { + snackbar(message = resources.getString(R.string.invalid_gpg_id)) + } + return@with + } + } + .filterIsInstance() + if (gpgIdentifiers.isEmpty()) { + error("Failed to parse identifiers from .gpg-id") + } val content = "$editPass\n$editExtra" val path = when { @@ -360,7 +388,7 @@ class PasswordCreationActivity : BasePgpActivity() { val result = withContext(Dispatchers.IO) { val outputStream = ByteArrayOutputStream() - repository.encrypt(content.byteInputStream(), outputStream) + repository.encrypt(gpgIdentifiers, content.byteInputStream(), outputStream) outputStream } val file = File(path) @@ -457,6 +485,23 @@ class PasswordCreationActivity : BasePgpActivity() { } } + @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 + } + } + companion object { private const val KEY_PWGEN_TYPE_CLASSIC = "classic" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d2c9ab06..9997f6aa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -299,6 +299,7 @@ Successfully imported TOTP configuration Failed to import TOTP configuration Exporting passwords… + Found .gpg-id, but it contains an invalid key ID, fingerprint or user ID File name must not contain \'/\', set directory above Directory Set GPG key for directory diff --git a/crypto-common/src/main/kotlin/app/passwordstore/crypto/CryptoHandler.kt b/crypto-common/src/main/kotlin/app/passwordstore/crypto/CryptoHandler.kt index f8995bf6..ea42af6d 100644 --- a/crypto-common/src/main/kotlin/app/passwordstore/crypto/CryptoHandler.kt +++ b/crypto-common/src/main/kotlin/app/passwordstore/crypto/CryptoHandler.kt @@ -14,13 +14,13 @@ import java.io.OutputStream public interface CryptoHandler { /** - * Decrypt the given [ciphertextStream] using a [secretKey] and [passphrase], and writes the - * resultant plaintext to [outputStream]. The returned [Result] should be checked to ensure it is - * **not** an instance of [com.github.michaelbull.result.Err] before the contents of + * Decrypt the given [ciphertextStream] using a set of potential [keys] and [passphrase], and + * writes the resultant plaintext to [outputStream]. The returned [Result] should be checked to + * ensure it is **not** an instance of [com.github.michaelbull.result.Err] before the contents of * [outputStream] are used. */ public fun decrypt( - secretKey: Key, + keys: List, passphrase: String, ciphertextStream: InputStream, outputStream: OutputStream, diff --git a/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt b/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt index 64ad426f..74f880a2 100644 --- a/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt +++ b/crypto-pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt @@ -12,12 +12,12 @@ import app.passwordstore.crypto.errors.UnknownError import com.github.michaelbull.result.Result import com.github.michaelbull.result.mapError import com.github.michaelbull.result.runCatching -import java.io.ByteArrayInputStream import java.io.InputStream import java.io.OutputStream import javax.inject.Inject import org.bouncycastle.openpgp.PGPPublicKeyRing import org.bouncycastle.openpgp.PGPPublicKeyRingCollection +import org.bouncycastle.openpgp.PGPSecretKeyRing import org.bouncycastle.openpgp.PGPSecretKeyRingCollection import org.pgpainless.PGPainless import org.pgpainless.decryption_verification.ConsumerOptions @@ -25,23 +25,25 @@ import org.pgpainless.encryption_signing.EncryptionOptions import org.pgpainless.encryption_signing.ProducerOptions import org.pgpainless.exception.WrongPassphraseException import org.pgpainless.key.protection.PasswordBasedSecretKeyRingProtector -import org.pgpainless.key.util.KeyRingUtils import org.pgpainless.util.Passphrase public class PGPainlessCryptoHandler @Inject constructor() : CryptoHandler { public override fun decrypt( - secretKey: PGPKey, + keys: List, passphrase: String, ciphertextStream: InputStream, outputStream: OutputStream, ): Result = runCatching { - val pgpSecretKeyRing = PGPainless.readKeyRing().secretKeyRing(secretKey.contents) - val keyringCollection = PGPSecretKeyRingCollection(listOf(pgpSecretKeyRing)) + if (keys.isEmpty()) throw NoKeysProvided("No keys provided for encryption") + val keyringCollection = + keys + .map { key -> PGPainless.readKeyRing().secretKeyRing(key.contents) } + .run(::PGPSecretKeyRingCollection) val protector = PasswordBasedSecretKeyRingProtector.forKey( - pgpSecretKeyRing, + keyringCollection.first(), Passphrase.fromPassword(passphrase) ) PGPainless.decryptAndOrVerify() @@ -68,17 +70,14 @@ public class PGPainlessCryptoHandler @Inject constructor() : CryptoHandler = runCatching { if (keys.isEmpty()) throw NoKeysProvided("No keys provided for encryption") - val publicKeyRings = arrayListOf() - val armoredKeys = - keys.joinToString("\n") { key -> key.contents.decodeToString() }.toByteArray() - val secKeysStream = ByteArrayInputStream(armoredKeys) - publicKeyRings.addAll( - KeyRingUtils.publicKeyRingCollectionFrom( - PGPainless.readKeyRing().secretKeyRingCollection(secKeysStream) - ) - ) - val pubKeysStream = ByteArrayInputStream(armoredKeys) - publicKeyRings.addAll(PGPainless.readKeyRing().publicKeyRingCollection(pubKeysStream)) + val publicKeyRings = + keys.mapNotNull(KeyUtils::tryParseKeyring).mapNotNull { keyRing -> + when (keyRing) { + is PGPPublicKeyRing -> keyRing + is PGPSecretKeyRing -> PGPainless.extractCertificate(keyRing) + else -> null + } + } require(keys.size == publicKeyRings.size) { "Failed to parse all keys: keys=${keys.size},parsed=${publicKeyRings.size}" } diff --git a/crypto-pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandlerTest.kt b/crypto-pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandlerTest.kt index 6a20fe25..80c8dc7c 100644 --- a/crypto-pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandlerTest.kt +++ b/crypto-pgpainless/src/test/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandlerTest.kt @@ -46,7 +46,7 @@ class PGPainlessCryptoHandlerTest { val plaintextStream = ByteArrayOutputStream() val decryptRes = cryptoHandler.decrypt( - secretKey, + listOf(secretKey), CryptoConstants.KEY_PASSPHRASE, ciphertextStream.toByteArray().inputStream(), plaintextStream, @@ -68,7 +68,7 @@ class PGPainlessCryptoHandlerTest { val plaintextStream = ByteArrayOutputStream() val result = cryptoHandler.decrypt( - secretKey, + listOf(secretKey), "very incorrect passphrase", ciphertextStream.toByteArray().inputStream(), plaintextStream,