aboutsummaryrefslogtreecommitdiff
path: root/crypto/pgpainless/src/main
diff options
context:
space:
mode:
authorHarsh Shandilya <me@msfjarvis.dev>2023-08-10 03:21:47 +0530
committerHarsh Shandilya <me@msfjarvis.dev>2023-08-10 03:31:08 +0530
commit959a56d7ffbc20db60721d7a09f399c8bdefe07e (patch)
tree0d9b14e0bb770fd6da2e04295f2c22c087142439 /crypto/pgpainless/src/main
parentefef72b6327c8e683c8844146e23d12104f12dd1 (diff)
refactor: un-flatten module structure
Diffstat (limited to 'crypto/pgpainless/src/main')
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/KeyUtils.kt52
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPDecryptOptions.kt21
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPEncryptOptions.kt35
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPIdentifier.kt128
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKey.kt12
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPKeyManager.kt154
-rw-r--r--crypto/pgpainless/src/main/kotlin/app/passwordstore/crypto/PGPainlessCryptoHandler.kt139
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"
+ }
+}