Implement support for .gpg-id (#2080)

This commit is contained in:
Harsh Shandilya 2022-08-24 22:44:02 +05:30 committed by GitHub
parent 3178ec9763
commit 8129495608
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 90 additions and 32 deletions

View file

@ -5,9 +5,10 @@
package app.passwordstore.data.crypto package app.passwordstore.data.crypto
import app.passwordstore.crypto.GpgIdentifier
import app.passwordstore.crypto.PGPKeyManager import app.passwordstore.crypto.PGPKeyManager
import app.passwordstore.crypto.PGPainlessCryptoHandler import app.passwordstore.crypto.PGPainlessCryptoHandler
import app.passwordstore.util.extensions.isOk import com.github.michaelbull.result.getAll
import com.github.michaelbull.result.unwrap import com.github.michaelbull.result.unwrap
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
@ -30,8 +31,12 @@ constructor(
withContext(Dispatchers.IO) { decryptPgp(password, message, out) } withContext(Dispatchers.IO) { decryptPgp(password, message, out) }
} }
suspend fun encrypt(content: ByteArrayInputStream, out: ByteArrayOutputStream) { suspend fun encrypt(
withContext(Dispatchers.IO) { encryptPgp(content, out) } identities: List<GpgIdentifier>,
content: ByteArrayInputStream,
out: ByteArrayOutputStream,
) {
withContext(Dispatchers.IO) { encryptPgp(identities, content, out) }
} }
private suspend fun decryptPgp( private suspend fun decryptPgp(
@ -41,11 +46,15 @@ constructor(
) { ) {
val keys = pgpKeyManager.getAllKeys().unwrap() val keys = pgpKeyManager.getAllKeys().unwrap()
// Iterates through the keys until the first successful decryption, then returns. // 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) { private suspend fun encryptPgp(
val keys = pgpKeyManager.getAllKeys().unwrap() identities: List<GpgIdentifier>,
content: ByteArrayInputStream,
out: ByteArrayOutputStream,
) {
val keys = identities.map { ident -> pgpKeyManager.getKeyById(ident) }.getAll()
pgpCryptoHandler.encrypt( pgpCryptoHandler.encrypt(
keys, keys,
content, content,

View file

@ -21,6 +21,7 @@ import app.passwordstore.util.extensions.unsafeLazy
import app.passwordstore.util.extensions.viewBinding import app.passwordstore.util.extensions.viewBinding
import app.passwordstore.util.settings.PreferenceKeys import app.passwordstore.util.settings.PreferenceKeys
import com.github.michaelbull.result.runCatching import com.github.michaelbull.result.runCatching
import com.github.michaelbull.result.unwrapError
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
@ -35,6 +36,8 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import logcat.LogPriority.ERROR
import logcat.logcat
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
@AndroidEntryPoint @AndroidEntryPoint
@ -140,7 +143,9 @@ class DecryptActivity : BasePgpActivity() {
lifecycleScope.launch(Dispatchers.Main) { lifecycleScope.launch(Dispatchers.Main) {
dialog.password.collectLatest { value -> dialog.password.collectLatest { value ->
if (value != null) { if (value != null) {
if (runCatching { decrypt(value) }.isErr()) { val res = runCatching { decrypt(value) }
if (res.isErr()) {
logcat(ERROR) { res.unwrapError().stackTraceToString() }
decrypt(isError = true) decrypt(isError = true)
} }
} }
@ -161,7 +166,6 @@ class DecryptActivity : BasePgpActivity() {
) )
outputStream outputStream
} }
require(result.size() != 0) { "Incorrect password" }
startAutoDismissTimer() startAutoDismissTimer()
val entry = passwordEntryFactory.create(result.toByteArray()) val entry = passwordEntryFactory.create(result.toByteArray())

View file

@ -24,8 +24,10 @@ import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged import androidx.core.widget.doAfterTextChanged
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import app.passwordstore.R import app.passwordstore.R
import app.passwordstore.crypto.GpgIdentifier
import app.passwordstore.data.crypto.CryptoRepository import app.passwordstore.data.crypto.CryptoRepository
import app.passwordstore.data.passfile.PasswordEntry import app.passwordstore.data.passfile.PasswordEntry
import app.passwordstore.data.repo.PasswordRepository
import app.passwordstore.databinding.PasswordCreationActivityBinding import app.passwordstore.databinding.PasswordCreationActivityBinding
import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment import app.passwordstore.ui.dialogs.DicewarePasswordGeneratorDialogFragment
import app.passwordstore.ui.dialogs.OtpImportDialogFragment import app.passwordstore.ui.dialogs.OtpImportDialogFragment
@ -332,6 +334,32 @@ class PasswordCreationActivity : BasePgpActivity() {
copyPasswordToClipboard(editPass) 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<GpgIdentifier>()
if (gpgIdentifiers.isEmpty()) {
error("Failed to parse identifiers from .gpg-id")
}
val content = "$editPass\n$editExtra" val content = "$editPass\n$editExtra"
val path = val path =
when { when {
@ -360,7 +388,7 @@ class PasswordCreationActivity : BasePgpActivity() {
val result = val result =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
repository.encrypt(content.byteInputStream(), outputStream) repository.encrypt(gpgIdentifiers, content.byteInputStream(), outputStream)
outputStream outputStream
} }
val file = File(path) 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 { companion object {
private const val KEY_PWGEN_TYPE_CLASSIC = "classic" private const val KEY_PWGEN_TYPE_CLASSIC = "classic"

View file

@ -299,6 +299,7 @@
<string name="otp_import_success">Successfully imported TOTP configuration</string> <string name="otp_import_success">Successfully imported TOTP configuration</string>
<string name="otp_import_failure">Failed to import TOTP configuration</string> <string name="otp_import_failure">Failed to import TOTP configuration</string>
<string name="exporting_passwords">Exporting passwords…</string> <string name="exporting_passwords">Exporting passwords…</string>
<string name="invalid_gpg_id">Found .gpg-id, but it contains an invalid key ID, fingerprint or user ID</string>
<string name="invalid_filename_text">File name must not contain \'/\', set directory above</string> <string name="invalid_filename_text">File name must not contain \'/\', set directory above</string>
<string name="directory_hint">Directory</string> <string name="directory_hint">Directory</string>
<string name="new_folder_set_gpg_key">Set GPG key for directory</string> <string name="new_folder_set_gpg_key">Set GPG key for directory</string>

View file

@ -14,13 +14,13 @@ import java.io.OutputStream
public interface CryptoHandler<Key> { public interface CryptoHandler<Key> {
/** /**
* Decrypt the given [ciphertextStream] using a [secretKey] and [passphrase], and writes the * Decrypt the given [ciphertextStream] using a set of potential [keys] and [passphrase], and
* resultant plaintext to [outputStream]. The returned [Result] should be checked to ensure it is * writes the resultant plaintext to [outputStream]. The returned [Result] should be checked to
* **not** an instance of [com.github.michaelbull.result.Err] before the contents of * ensure it is **not** an instance of [com.github.michaelbull.result.Err] before the contents of
* [outputStream] are used. * [outputStream] are used.
*/ */
public fun decrypt( public fun decrypt(
secretKey: Key, keys: List<Key>,
passphrase: String, passphrase: String,
ciphertextStream: InputStream, ciphertextStream: InputStream,
outputStream: OutputStream, outputStream: OutputStream,

View file

@ -12,12 +12,12 @@ import app.passwordstore.crypto.errors.UnknownError
import com.github.michaelbull.result.Result import com.github.michaelbull.result.Result
import com.github.michaelbull.result.mapError import com.github.michaelbull.result.mapError
import com.github.michaelbull.result.runCatching import com.github.michaelbull.result.runCatching
import java.io.ByteArrayInputStream
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream import java.io.OutputStream
import javax.inject.Inject import javax.inject.Inject
import org.bouncycastle.openpgp.PGPPublicKeyRing import org.bouncycastle.openpgp.PGPPublicKeyRing
import org.bouncycastle.openpgp.PGPPublicKeyRingCollection import org.bouncycastle.openpgp.PGPPublicKeyRingCollection
import org.bouncycastle.openpgp.PGPSecretKeyRing
import org.bouncycastle.openpgp.PGPSecretKeyRingCollection import org.bouncycastle.openpgp.PGPSecretKeyRingCollection
import org.pgpainless.PGPainless import org.pgpainless.PGPainless
import org.pgpainless.decryption_verification.ConsumerOptions 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.encryption_signing.ProducerOptions
import org.pgpainless.exception.WrongPassphraseException import org.pgpainless.exception.WrongPassphraseException
import org.pgpainless.key.protection.PasswordBasedSecretKeyRingProtector import org.pgpainless.key.protection.PasswordBasedSecretKeyRingProtector
import org.pgpainless.key.util.KeyRingUtils
import org.pgpainless.util.Passphrase import org.pgpainless.util.Passphrase
public class PGPainlessCryptoHandler @Inject constructor() : CryptoHandler<PGPKey> { public class PGPainlessCryptoHandler @Inject constructor() : CryptoHandler<PGPKey> {
public override fun decrypt( public override fun decrypt(
secretKey: PGPKey, keys: List<PGPKey>,
passphrase: String, passphrase: String,
ciphertextStream: InputStream, ciphertextStream: InputStream,
outputStream: OutputStream, outputStream: OutputStream,
): Result<Unit, CryptoHandlerException> = ): Result<Unit, CryptoHandlerException> =
runCatching { runCatching {
val pgpSecretKeyRing = PGPainless.readKeyRing().secretKeyRing(secretKey.contents) if (keys.isEmpty()) throw NoKeysProvided("No keys provided for encryption")
val keyringCollection = PGPSecretKeyRingCollection(listOf(pgpSecretKeyRing)) val keyringCollection =
keys
.map { key -> PGPainless.readKeyRing().secretKeyRing(key.contents) }
.run(::PGPSecretKeyRingCollection)
val protector = val protector =
PasswordBasedSecretKeyRingProtector.forKey( PasswordBasedSecretKeyRingProtector.forKey(
pgpSecretKeyRing, keyringCollection.first(),
Passphrase.fromPassword(passphrase) Passphrase.fromPassword(passphrase)
) )
PGPainless.decryptAndOrVerify() PGPainless.decryptAndOrVerify()
@ -68,17 +70,14 @@ public class PGPainlessCryptoHandler @Inject constructor() : CryptoHandler<PGPKe
): Result<Unit, CryptoHandlerException> = ): Result<Unit, CryptoHandlerException> =
runCatching { runCatching {
if (keys.isEmpty()) throw NoKeysProvided("No keys provided for encryption") if (keys.isEmpty()) throw NoKeysProvided("No keys provided for encryption")
val publicKeyRings = arrayListOf<PGPPublicKeyRing>() val publicKeyRings =
val armoredKeys = keys.mapNotNull(KeyUtils::tryParseKeyring).mapNotNull { keyRing ->
keys.joinToString("\n") { key -> key.contents.decodeToString() }.toByteArray() when (keyRing) {
val secKeysStream = ByteArrayInputStream(armoredKeys) is PGPPublicKeyRing -> keyRing
publicKeyRings.addAll( is PGPSecretKeyRing -> PGPainless.extractCertificate(keyRing)
KeyRingUtils.publicKeyRingCollectionFrom( else -> null
PGPainless.readKeyRing().secretKeyRingCollection(secKeysStream) }
) }
)
val pubKeysStream = ByteArrayInputStream(armoredKeys)
publicKeyRings.addAll(PGPainless.readKeyRing().publicKeyRingCollection(pubKeysStream))
require(keys.size == publicKeyRings.size) { require(keys.size == publicKeyRings.size) {
"Failed to parse all keys: keys=${keys.size},parsed=${publicKeyRings.size}" "Failed to parse all keys: keys=${keys.size},parsed=${publicKeyRings.size}"
} }

View file

@ -46,7 +46,7 @@ class PGPainlessCryptoHandlerTest {
val plaintextStream = ByteArrayOutputStream() val plaintextStream = ByteArrayOutputStream()
val decryptRes = val decryptRes =
cryptoHandler.decrypt( cryptoHandler.decrypt(
secretKey, listOf(secretKey),
CryptoConstants.KEY_PASSPHRASE, CryptoConstants.KEY_PASSPHRASE,
ciphertextStream.toByteArray().inputStream(), ciphertextStream.toByteArray().inputStream(),
plaintextStream, plaintextStream,
@ -68,7 +68,7 @@ class PGPainlessCryptoHandlerTest {
val plaintextStream = ByteArrayOutputStream() val plaintextStream = ByteArrayOutputStream()
val result = val result =
cryptoHandler.decrypt( cryptoHandler.decrypt(
secretKey, listOf(secretKey),
"very incorrect passphrase", "very incorrect passphrase",
ciphertextStream.toByteArray().inputStream(), ciphertextStream.toByteArray().inputStream(),
plaintextStream, plaintextStream,