diff options
Diffstat (limited to 'crypto/pgpainless/src/main')
7 files changed, 541 insertions, 0 deletions
diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt new file mode 100644 index 00000000..5b23dc18 --- /dev/null +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt @@ -0,0 +1,52 @@ +/* + * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.crypto + +import app.passwordstore.crypto.PGPIdentifier.KeyId +import app.passwordstore.crypto.PGPIdentifier.UserId +import com.github.michaelbull.result.get +import com.github.michaelbull.result.runCatching +import org.bouncycastle.openpgp.PGPKeyRing +import org.pgpainless.PGPainless +import org.pgpainless.key.parsing.KeyRingReader + +/** Utility methods to deal with [PGPKey]s. */ +public object KeyUtils { + /** + * Attempts to parse a [PGPKeyRing] from a given [key]. The key is first tried as a secret key and + * then as a public one before the method gives up and returns null. + */ + public fun tryParseKeyring(key: PGPKey): PGPKeyRing? { + return runCatching { KeyRingReader.readKeyRing(key.contents.inputStream()) }.get() + } + + /** Parses a [PGPKeyRing] from the given [key] and calculates its long key ID */ + public fun tryGetId(key: PGPKey): KeyId? { + val keyRing = tryParseKeyring(key) ?: return null + return KeyId(keyRing.publicKey.keyID) + } + + /** + * Attempts to parse the given [PGPKey] into a [PGPKeyRing] and obtains the [UserId] of the + * corresponding public key. + */ + public fun tryGetEmail(key: PGPKey): UserId? { + val keyRing = tryParseKeyring(key) ?: return null + return UserId(keyRing.publicKey.userIDs.next()) + } + + /** + * Tests if the given [key] can be used for encryption, which is a bare minimum necessity for the + * app. + */ + public fun isKeyUsable(key: PGPKey): Boolean { + return runCatching { + val keyRing = tryParseKeyring(key) ?: return false + PGPainless.inspectKeyRing(keyRing).isUsableForEncryption + } + .get() != null + } +} diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPDecryptOptions.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPDecryptOptions.kt new file mode 100644 index 00000000..15ce92f0 --- /dev/null +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPDecryptOptions.kt @@ -0,0 +1,21 @@ +package app.passwordstore.crypto + +/** [CryptoOptions] implementation for PGPainless decrypt operations. */ +public class PGPDecryptOptions +private constructor( + private val values: Map<String, Boolean>, +) : CryptoOptions { + + override fun isOptionEnabled(option: String): Boolean { + return values.getOrDefault(option, false) + } + + /** Implementation of a builder pattern for [PGPDecryptOptions]. */ + public class Builder { + + /** Build the final [PGPDecryptOptions] object. */ + public fun build(): PGPDecryptOptions { + return PGPDecryptOptions(emptyMap()) + } + } +} diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPEncryptOptions.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPEncryptOptions.kt new file mode 100644 index 00000000..90de6b51 --- /dev/null +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPEncryptOptions.kt @@ -0,0 +1,35 @@ +package app.passwordstore.crypto + +/** [CryptoOptions] implementation for PGPainless encrypt operations. */ +public class PGPEncryptOptions +private constructor( + private val values: Map<String, Boolean>, +) : CryptoOptions { + + internal companion object { + const val ASCII_ARMOR = "ASCII_ARMOR" + } + + override fun isOptionEnabled(option: String): Boolean { + return values.getOrDefault(option, false) + } + + /** Implementation of a builder pattern for [PGPEncryptOptions]. */ + public class Builder { + private val optionsMap = mutableMapOf<String, Boolean>() + + /** + * Toggle whether the encryption operation output will be ASCII armored or in OpenPGP's binary + * format. + */ + public fun withAsciiArmor(enabled: Boolean): Builder { + optionsMap[ASCII_ARMOR] = enabled + return this + } + + /** Build the final [PGPEncryptOptions] object. */ + public fun build(): PGPEncryptOptions { + return PGPEncryptOptions(optionsMap) + } + } +} diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPIdentifier.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPIdentifier.kt new file mode 100644 index 00000000..98a1de2e --- /dev/null +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPIdentifier.kt @@ -0,0 +1,128 @@ +/* + * Copyright © 2014-2022 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.crypto + +import java.util.Locale +import java.util.regex.Pattern + +/** Supertype for valid identifiers of PGP keys. */ +public sealed class PGPIdentifier { + + /** A [PGPIdentifier] that represents either a long key ID or a fingerprint. */ + public data class KeyId(val id: Long) : PGPIdentifier() { + override fun toString(): String { + return convertKeyIdToHex(id) + } + + /** Convert a [Long] key ID to a formatted string. */ + private fun convertKeyIdToHex(keyId: Long): String { + return convertKeyIdToHex32bit(keyId shr HEX_32_BIT_COUNT) + convertKeyIdToHex32bit(keyId) + } + + /** + * Converts [keyId] to an unsigned [Long] then uses [java.lang.Long.toHexString] to convert it + * to a lowercase hex ID. + */ + private fun convertKeyIdToHex32bit(keyId: Long): String { + var hexString = java.lang.Long.toHexString(keyId and HEX_32_BITMASK).lowercase(Locale.ENGLISH) + while (hexString.length < HEX_32_STRING_LENGTH) { + hexString = "0$hexString" + } + return hexString + } + } + + /** + * A [PGPIdentifier] that represents the textual name/email combination corresponding to a key. + * Despite the [email] property in this class, the value is not guaranteed to be a valid email. + */ + public data class UserId(val email: String) : PGPIdentifier() { + override fun toString(): String { + return email + } + } + + public companion object { + private const val HEX_RADIX = 16 + private const val HEX_32_BIT_COUNT = 32 + private const val HEX_32_BITMASK = 0xffffffffL + private const val HEX_32_STRING_LENGTH = 8 + private const val TRUNCATED_FINGERPRINT_LENGTH = 16 + + /** + * Attempts to parse an untyped String identifier into a concrete subtype of [PGPIdentifier]. + */ + @Suppress("ReturnCount") + public fun fromString(identifier: String): PGPIdentifier? { + if (identifier.isEmpty()) return null + // Match long key IDs: + // FF22334455667788 or 0xFF22334455667788 + val maybeLongKeyId = + identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F\\d]{16}".toRegex()) } + if (maybeLongKeyId != null) { + val keyId = maybeLongKeyId.toULong(HEX_RADIX) + return KeyId(keyId.toLong()) + } + + // Match fingerprints: + // FF223344556677889900112233445566778899 or 0xFF223344556677889900112233445566778899 + val maybeFingerprint = + identifier.removePrefix("0x").takeIf { it.matches("[a-fA-F\\d]{40}".toRegex()) } + if (maybeFingerprint != null) { + // Truncating to the long key ID is not a security issue since OpenKeychain only + // accepts + // non-ambiguous key IDs. + val keyId = maybeFingerprint.takeLast(TRUNCATED_FINGERPRINT_LENGTH).toULong(HEX_RADIX) + return KeyId(keyId.toLong()) + } + + return splitUserId(identifier)?.let { UserId(it) } + } + + private object UserIdRegex { + val PATTERN: Pattern = Pattern.compile("^(.*?)(?: \\((.*)\\))?(?: <(.*)>)?$") + const val NAME = 1 + const val EMAIL = 3 + } + + private object EmailRegex { + val PATTERN: Pattern = Pattern.compile("^<?\"?([^<>\"]*@[^<>\"]*[.]?[^<>\"]*)\"?>?$") + const val EMAIL = 1 + } + + /** + * Takes a 'Name (Comment) <Email>' user ID in any of its permutations and attempts to extract + * an email from it. + */ + @Suppress("NestedBlockDepth") + private fun splitUserId(userId: String): String? { + if (userId.isNotEmpty()) { + val matcher = UserIdRegex.PATTERN.matcher(userId) + if (matcher.matches()) { + var name = + if (matcher.group(UserIdRegex.NAME)?.isEmpty() == true) null + else matcher.group(UserIdRegex.NAME) + var email = matcher.group(UserIdRegex.EMAIL) + if (email != null && name != null) { + val emailMatcher = EmailRegex.PATTERN.matcher(name) + if (emailMatcher.matches() && email == emailMatcher.group(EmailRegex.EMAIL)) { + email = emailMatcher.group(EmailRegex.EMAIL) + name = null + } + } + if (email == null && name != null) { + val emailMatcher = EmailRegex.PATTERN.matcher(name) + if (emailMatcher.matches()) { + email = emailMatcher.group(EmailRegex.EMAIL) + } + } + return email + } + } + return null + } + } +} diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKey.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKey.kt new file mode 100644 index 00000000..a33655d4 --- /dev/null +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKey.kt @@ -0,0 +1,12 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.crypto + +/** + * A simple value class wrapping over a [ByteArray] that can represent a PGP key. The implementation + * details of public/ private parts as well as identities are deferred to [PGPKeyManager]. + */ +@JvmInline public value class PGPKey(public val contents: ByteArray) diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt new file mode 100644 index 00000000..aed1acf2 --- /dev/null +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt @@ -0,0 +1,154 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ +@file:Suppress("BlockingMethodInNonBlockingContext") + +package app.passwordstore.crypto + +import androidx.annotation.VisibleForTesting +import app.passwordstore.crypto.KeyUtils.isKeyUsable +import app.passwordstore.crypto.KeyUtils.tryGetId +import app.passwordstore.crypto.KeyUtils.tryParseKeyring +import app.passwordstore.crypto.errors.InvalidKeyException +import app.passwordstore.crypto.errors.KeyAlreadyExistsException +import app.passwordstore.crypto.errors.KeyDeletionFailedException +import app.passwordstore.crypto.errors.KeyDirectoryUnavailableException +import app.passwordstore.crypto.errors.KeyNotFoundException +import app.passwordstore.crypto.errors.NoKeysAvailableException +import app.passwordstore.crypto.errors.UnusableKeyException +import app.passwordstore.util.coroutines.runSuspendCatching +import com.github.michaelbull.result.Result +import com.github.michaelbull.result.unwrap +import java.io.File +import javax.inject.Inject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.bouncycastle.openpgp.PGPPublicKeyRing +import org.bouncycastle.openpgp.PGPSecretKeyRing +import org.pgpainless.PGPainless +import org.pgpainless.util.selection.userid.SelectUserId + +public class PGPKeyManager +@Inject +constructor( + filesDir: String, + private val dispatcher: CoroutineDispatcher, +) : KeyManager<PGPKey, PGPIdentifier> { + + private val keyDir = File(filesDir, KEY_DIR_NAME) + + /** @see KeyManager.addKey */ + override suspend fun addKey(key: PGPKey, replace: Boolean): Result<PGPKey, Throwable> = + withContext(dispatcher) { + runSuspendCatching { + if (!keyDirExists()) throw KeyDirectoryUnavailableException + val incomingKeyRing = tryParseKeyring(key) ?: throw InvalidKeyException + if (!isKeyUsable(key)) throw UnusableKeyException + val keyFile = File(keyDir, "${tryGetId(key)}.$KEY_EXTENSION") + if (keyFile.exists()) { + val existingKeyBytes = keyFile.readBytes() + val existingKeyRing = + tryParseKeyring(PGPKey(existingKeyBytes)) ?: throw InvalidKeyException + when { + existingKeyRing is PGPPublicKeyRing && incomingKeyRing is PGPSecretKeyRing -> { + keyFile.writeBytes(key.contents) + return@runSuspendCatching key + } + existingKeyRing is PGPPublicKeyRing && incomingKeyRing is PGPPublicKeyRing -> { + val updatedPublicKey = PGPainless.mergeCertificate(existingKeyRing, incomingKeyRing) + val keyBytes = PGPainless.asciiArmor(updatedPublicKey).encodeToByteArray() + keyFile.writeBytes(keyBytes) + return@runSuspendCatching key + } + } + // Check for replace flag first and if it is false, throw an error + if (!replace) + throw KeyAlreadyExistsException( + tryGetId(key)?.toString() ?: "Failed to retrieve key ID" + ) + if (!keyFile.delete()) throw KeyDeletionFailedException + } + + keyFile.writeBytes(key.contents) + + key + } + } + + /** @see KeyManager.removeKey */ + override suspend fun removeKey(identifier: PGPIdentifier): Result<Unit, Throwable> = + withContext(dispatcher) { + runSuspendCatching { + if (!keyDirExists()) throw KeyDirectoryUnavailableException + val key = getKeyById(identifier).unwrap() + val keyFile = File(keyDir, "${tryGetId(key)}.$KEY_EXTENSION") + if (keyFile.exists()) { + if (!keyFile.delete()) throw KeyDeletionFailedException + } + } + } + + /** @see KeyManager.getKeyById */ + override suspend fun getKeyById(id: PGPIdentifier): Result<PGPKey, Throwable> = + withContext(dispatcher) { + runSuspendCatching { + if (!keyDirExists()) throw KeyDirectoryUnavailableException + val keyFiles = keyDir.listFiles() + if (keyFiles.isNullOrEmpty()) throw NoKeysAvailableException + val keys = keyFiles.map { file -> PGPKey(file.readBytes()) } + + val matchResult = + when (id) { + is PGPIdentifier.KeyId -> { + val keyIdMatch = + keys + .map { key -> key to tryGetId(key) } + .firstOrNull { (_, keyId) -> keyId?.id == id.id } + keyIdMatch?.first + } + is PGPIdentifier.UserId -> { + val selector = SelectUserId.byEmail(id.email) + val userIdMatch = + keys + .map { key -> key to tryParseKeyring(key) } + .firstOrNull { (_, keyRing) -> selector.firstMatch(keyRing) != null } + userIdMatch?.first + } + } + + if (matchResult != null) { + return@runSuspendCatching matchResult + } + + throw KeyNotFoundException("$id") + } + } + + /** @see KeyManager.getAllKeys */ + override suspend fun getAllKeys(): Result<List<PGPKey>, Throwable> = + withContext(dispatcher) { + runSuspendCatching { + if (!keyDirExists()) throw KeyDirectoryUnavailableException + val keyFiles = keyDir.listFiles() + if (keyFiles.isNullOrEmpty()) return@runSuspendCatching emptyList() + keyFiles.map { keyFile -> PGPKey(keyFile.readBytes()) }.toList() + } + } + + /** @see KeyManager.getKeyById */ + override suspend fun getKeyId(key: PGPKey): PGPIdentifier? = tryGetId(key) + + /** Checks if [keyDir] exists and attempts to create it if not. */ + private fun keyDirExists(): Boolean { + return keyDir.exists() || keyDir.mkdirs() + } + + public companion object { + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal const val KEY_DIR_NAME: String = "keys" + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal const val KEY_EXTENSION: String = "key" + } +} diff --git a/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt new file mode 100644 index 00000000..a7087acf --- /dev/null +++ b/crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt @@ -0,0 +1,139 @@ +/* + * Copyright © 2014-2021 The Android Password Store Authors. All Rights Reserved. + * SPDX-License-Identifier: GPL-3.0-only + */ + +package app.passwordstore.crypto + +import app.passwordstore.crypto.errors.CryptoHandlerException +import app.passwordstore.crypto.errors.IncorrectPassphraseException +import app.passwordstore.crypto.errors.NoKeysProvidedException +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.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.bouncycastle.util.io.Streams +import org.pgpainless.PGPainless +import org.pgpainless.decryption_verification.ConsumerOptions +import org.pgpainless.encryption_signing.EncryptionOptions +import org.pgpainless.encryption_signing.ProducerOptions +import org.pgpainless.exception.WrongPassphraseException +import org.pgpainless.key.protection.SecretKeyRingProtector +import org.pgpainless.util.Passphrase + +public class PGPainlessCryptoHandler @Inject constructor() : + CryptoHandler<PGPKey, PGPEncryptOptions, PGPDecryptOptions> { + + /** + * Decrypts the given [ciphertextStream] using [PGPainless] and writes the decrypted output to + * [outputStream]. The provided [passphrase] is wrapped in a [SecretKeyRingProtector] and the + * [keys] argument is defensively checked to ensure it has at least one key present. + * + * @see CryptoHandler.decrypt + */ + public override fun decrypt( + keys: List<PGPKey>, + passphrase: String, + ciphertextStream: InputStream, + outputStream: OutputStream, + options: PGPDecryptOptions, + ): Result<Unit, CryptoHandlerException> = + runCatching { + if (keys.isEmpty()) { + throw NoKeysProvidedException + } + val keyringCollection = + keys + .map { key -> PGPainless.readKeyRing().secretKeyRing(key.contents) } + .run(::PGPSecretKeyRingCollection) + val protector = SecretKeyRingProtector.unlockAnyKeyWith(Passphrase.fromPassword(passphrase)) + val decryptionStream = + PGPainless.decryptAndOrVerify() + .onInputStream(ciphertextStream) + .withOptions( + ConsumerOptions() + .addDecryptionKeys(keyringCollection, protector) + .addDecryptionPassphrase(Passphrase.fromPassword(passphrase)) + ) + Streams.pipeAll(decryptionStream, outputStream) + decryptionStream.close() + keyringCollection.forEach { keyRing -> + check(decryptionStream.metadata.isEncryptedFor(keyRing)) { + "Stream should be encrypted for ${keyRing.secretKey.keyID} but wasn't" + } + } + return@runCatching + } + .mapError { error -> + when (error) { + is WrongPassphraseException -> IncorrectPassphraseException(error) + is CryptoHandlerException -> error + else -> UnknownError(error) + } + } + + /** + * Encrypts the provided [plaintextStream] and writes the encrypted output to [outputStream]. The + * [keys] argument is defensively checked to contain at least one key. + * + * @see CryptoHandler.encrypt + */ + public override fun encrypt( + keys: List<PGPKey>, + plaintextStream: InputStream, + outputStream: OutputStream, + options: PGPEncryptOptions, + ): Result<Unit, CryptoHandlerException> = + runCatching { + if (keys.isEmpty()) { + throw NoKeysProvidedException + } + 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.size} keys were provided but only ${publicKeyRings.size} were valid" + } + if (publicKeyRings.isEmpty()) { + throw NoKeysProvidedException + } + val publicKeyRingCollection = PGPPublicKeyRingCollection(publicKeyRings) + val encryptionOptions = EncryptionOptions().addRecipients(publicKeyRingCollection) + val producerOptions = + ProducerOptions.encrypt(encryptionOptions) + .setAsciiArmor(options.isOptionEnabled(PGPEncryptOptions.ASCII_ARMOR)) + val encryptionStream = + PGPainless.encryptAndOrSign().onOutputStream(outputStream).withOptions(producerOptions) + Streams.pipeAll(plaintextStream, encryptionStream) + encryptionStream.close() + val result = encryptionStream.result + publicKeyRingCollection.forEach { keyRing -> + require(result.isEncryptedFor(keyRing)) { + "Stream should be encrypted for ${keyRing.publicKey.keyID} but wasn't" + } + } + } + .mapError { error -> + when (error) { + is CryptoHandlerException -> error + else -> UnknownError(error) + } + } + + /** Runs a naive check on the extension for the given [fileName] to check if it is a PGP file. */ + public override fun canHandle(fileName: String): Boolean { + return fileName.substringAfterLast('.', "") == "gpg" + } +} |