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
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<GpgIdentifier>,
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<GpgIdentifier>,
content: ByteArrayInputStream,
out: ByteArrayOutputStream,
) {
val keys = identities.map { ident -> pgpKeyManager.getKeyById(ident) }.getAll()
pgpCryptoHandler.encrypt(
keys,
content,

View file

@ -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())

View file

@ -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<GpgIdentifier>()
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"

View file

@ -299,6 +299,7 @@
<string name="otp_import_success">Successfully imported TOTP configuration</string>
<string name="otp_import_failure">Failed to import TOTP configuration</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="directory_hint">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> {
/**
* 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<Key>,
passphrase: String,
ciphertextStream: InputStream,
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.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<PGPKey> {
public override fun decrypt(
secretKey: PGPKey,
keys: List<PGPKey>,
passphrase: String,
ciphertextStream: InputStream,
outputStream: OutputStream,
): Result<Unit, CryptoHandlerException> =
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<PGPKe
): Result<Unit, CryptoHandlerException> =
runCatching {
if (keys.isEmpty()) throw NoKeysProvided("No keys provided for encryption")
val publicKeyRings = arrayListOf<PGPPublicKeyRing>()
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}"
}

View file

@ -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,