Implement support for .gpg-id
(#2080)
This commit is contained in:
parent
3178ec9763
commit
8129495608
7 changed files with 90 additions and 32 deletions
|
@ -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,
|
||||||
|
|
|
@ -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())
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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}"
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue