From db0d753867a129570977320bbab2747aacd7d891 Mon Sep 17 00:00:00 2001 From: Paul Schaub Date: Thu, 12 Jun 2025 13:35:24 +0200 Subject: [PATCH] Implement update-key command properly --- .../secretkeyring/OpenPGPKeyUpdater.kt | 221 ++++++++++++++++++ .../kotlin/org/pgpainless/policy/Policy.kt | 62 +++++ .../org/pgpainless/sop/UpdateKeyImpl.kt | 16 +- 3 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 pgpainless-core/src/main/kotlin/org/pgpainless/key/modification/secretkeyring/OpenPGPKeyUpdater.kt diff --git a/pgpainless-core/src/main/kotlin/org/pgpainless/key/modification/secretkeyring/OpenPGPKeyUpdater.kt b/pgpainless-core/src/main/kotlin/org/pgpainless/key/modification/secretkeyring/OpenPGPKeyUpdater.kt new file mode 100644 index 00000000..def452ac --- /dev/null +++ b/pgpainless-core/src/main/kotlin/org/pgpainless/key/modification/secretkeyring/OpenPGPKeyUpdater.kt @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: 2025 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.key.modification.secretkeyring + +import java.util.* +import org.bouncycastle.bcpg.sig.PreferredAEADCiphersuites.Combination +import org.bouncycastle.openpgp.api.KeyPairGeneratorCallback +import org.bouncycastle.openpgp.api.MessageEncryptionMechanism +import org.bouncycastle.openpgp.api.OpenPGPKey +import org.bouncycastle.openpgp.api.OpenPGPKeyEditor +import org.bouncycastle.openpgp.api.SignatureParameters +import org.bouncycastle.openpgp.operator.PGPKeyPairGenerator +import org.pgpainless.PGPainless +import org.pgpainless.algorithm.Feature +import org.pgpainless.bouncycastle.PolicyAdapter +import org.pgpainless.bouncycastle.extensions.getKeyVersion +import org.pgpainless.bouncycastle.extensions.toAEADCipherModes +import org.pgpainless.bouncycastle.extensions.toCompressionAlgorithms +import org.pgpainless.bouncycastle.extensions.toHashAlgorithms +import org.pgpainless.bouncycastle.extensions.toSymmetricKeyAlgorithms +import org.pgpainless.key.protection.SecretKeyRingProtector +import org.pgpainless.policy.Policy + +class OpenPGPKeyUpdater( + private var key: OpenPGPKey, + private val protector: SecretKeyRingProtector, + private val api: PGPainless = PGPainless.getInstance(), + private val policy: Policy = api.algorithmPolicy, + private val referenceTime: Date = Date() +) { + + init { + key = + OpenPGPKey( + key.pgpSecretKeyRing, api.implementation, PolicyAdapter(Policy.wildcardPolicy())) + } + + private val keyEditor = OpenPGPKeyEditor(key, protector) + + fun extendExpirationIfExpiresBefore( + expiresBeforeSeconds: Long, + newExpirationTimeSecondsFromNow: Long? = _5YEARS + ) = apply { + require(expiresBeforeSeconds > 0) { + "Time period to check expiration within MUST be positive." + } + require(newExpirationTimeSecondsFromNow == null || newExpirationTimeSecondsFromNow > 0) { + "New expiration period MUST be null or positive." + } + } + + fun replaceRejectedAlgorithmPreferencesAndFeatures(addNewAlgorithms: Boolean = false) = apply { + val features = key.primaryKey.getFeatures(referenceTime)?.features ?: 0 + val newFeatures = + Feature.fromBitmask(features.toInt()) + // Filter out unsupported features + .filter { policy.featurePolicy.isAcceptable(it) } + .toSet() + // Optionally add in new capabilities + .plus( + if (addNewAlgorithms) policy.keyGenerationAlgorithmSuite.features ?: listOf() + else listOf()) + .toTypedArray() + .let { Feature.toBitmask(*it) } + + // Hash Algs + val hashAlgs = key.primaryKey.hashAlgorithmPreferences.toHashAlgorithms() + val newHashAlgs = + hashAlgs + // Filter out unsupported hash algorithms + .filter { policy.dataSignatureHashAlgorithmPolicy.isAcceptable(it) } + // Optionally add in new hash algorithms + .plus( + if (addNewAlgorithms) + policy.keyGenerationAlgorithmSuite.hashAlgorithms ?: listOf() + else listOf()) + .toSet() + + // Sym Algs + val symAlgs = key.primaryKey.symmetricCipherPreferences.toSymmetricKeyAlgorithms() + val newSymAlgs = + symAlgs + .filter { + policy.messageEncryptionAlgorithmPolicy.symmetricAlgorithmPolicy.isAcceptable( + it) + } + .plus( + if (addNewAlgorithms) + policy.keyGenerationAlgorithmSuite.symmetricKeyAlgorithms ?: listOf() + else listOf()) + .toSet() + + // Comp Algs + val compAlgs = key.primaryKey.compressionAlgorithmPreferences.toCompressionAlgorithms() + val newCompAlgs = + compAlgs + .filter { policy.compressionAlgorithmPolicy.isAcceptable(it) } + .plus( + if (addNewAlgorithms) + policy.keyGenerationAlgorithmSuite.compressionAlgorithms ?: listOf() + else listOf()) + .toSet() + + // AEAD Prefs + val aeadAlgs = key.primaryKey.aeadCipherSuitePreferences.toAEADCipherModes() + val newAeadAlgs = + aeadAlgs + .filter { + policy.messageEncryptionAlgorithmPolicy.isAcceptable( + MessageEncryptionMechanism.aead( + it.ciphermode.algorithmId, it.aeadAlgorithm.algorithmId)) + } + .plus(policy.keyGenerationAlgorithmSuite.aeadAlgorithms ?: listOf()) + .toSet() + + if (features != newFeatures || + hashAlgs != newHashAlgs || + symAlgs != newSymAlgs || + compAlgs != newCompAlgs || + aeadAlgs != newAeadAlgs) { + keyEditor.addDirectKeySignature( + SignatureParameters.Callback.modifyHashedSubpackets { sigGen -> + sigGen.apply { + setKeyFlags(key.primaryKey.keyFlags?.flags ?: 0) + setFeature(true, newFeatures) + setPreferredHashAlgorithms( + true, newHashAlgs.map { it.algorithmId }.toIntArray()) + setPreferredSymmetricAlgorithms( + true, newSymAlgs.map { it.algorithmId }.toIntArray()) + setPreferredCompressionAlgorithms( + true, newCompAlgs.map { it.algorithmId }.toIntArray()) + setPreferredAEADCiphersuites( + true, + newAeadAlgs + .map { + Combination( + it.ciphermode.algorithmId, it.aeadAlgorithm.algorithmId) + } + .toTypedArray()) + } + }) + } + } + + fun replaceWeakSubkeys( + revokeWeakKeys: Boolean = true, + signingKeysOnly: Boolean + ): OpenPGPKeyUpdater = apply { + replaceWeakSigningSubkeys(revokeWeakKeys) + if (!signingKeysOnly) { + replaceWeakEncryptionSubkeys(revokeWeakKeys) + } + } + + fun replaceWeakEncryptionSubkeys( + revokeWeakKeys: Boolean, + keyPairGeneratorCallback: KeyPairGeneratorCallback = + KeyPairGeneratorCallback.encryptionKey() + ) { + val weakEncryptionKeys = + key.getEncryptionKeys(referenceTime).filterNot { + policy.publicKeyAlgorithmPolicy.isAcceptable( + it.algorithm, it.pgpPublicKey.bitStrength) + } + + if (weakEncryptionKeys.isNotEmpty()) { + keyEditor.addEncryptionSubkey(keyPairGeneratorCallback) + } + + if (revokeWeakKeys) { + weakEncryptionKeys + .filterNot { it.keyIdentifier.matches(key.primaryKey.keyIdentifier) } + .forEach { keyEditor.revokeComponentKey(it) } + } + } + + fun replaceWeakSigningSubkeys( + revokeWeakKeys: Boolean, + keyPairGenerator: PGPKeyPairGenerator = provideKeyPairGenerator(), + keyPairGeneratorCallback: KeyPairGeneratorCallback = KeyPairGeneratorCallback.signingKey() + ) { + val weakSigningKeys = + key.getSigningKeys(referenceTime).filterNot { + policy.publicKeyAlgorithmPolicy.isAcceptable( + it.algorithm, it.pgpPublicKey.bitStrength) + } + + if (weakSigningKeys.isNotEmpty()) { + keyEditor.addSigningSubkey(keyPairGeneratorCallback) + } + + if (revokeWeakKeys) { + weakSigningKeys + .filterNot { it.keyIdentifier.matches(key.primaryKey.keyIdentifier) } + .forEach { keyEditor.revokeComponentKey(it) } + } + + keyPairGeneratorCallback.generateFrom(keyPairGenerator) + } + + private fun provideKeyPairGenerator(): PGPKeyPairGenerator { + return api.implementation + .pgpKeyPairGeneratorProvider() + .get(key.primaryKey.getKeyVersion().numeric, referenceTime) + } + + fun finish(): OpenPGPKey { + return keyEditor.done() + } + + companion object { + const val SECOND: Long = 1000 + const val MINUTE: Long = 60 * SECOND + const val HOUR: Long = 60 * MINUTE + const val DAY: Long = 24 * HOUR + const val YEAR: Long = 365 * DAY + const val _5YEARS: Long = 5 * YEAR + } +} diff --git a/pgpainless-core/src/main/kotlin/org/pgpainless/policy/Policy.kt b/pgpainless-core/src/main/kotlin/org/pgpainless/policy/Policy.kt index 872aa972..5d8ba4d0 100644 --- a/pgpainless-core/src/main/kotlin/org/pgpainless/policy/Policy.kt +++ b/pgpainless-core/src/main/kotlin/org/pgpainless/policy/Policy.kt @@ -21,6 +21,7 @@ class Policy { val messageDecryptionAlgorithmPolicy: MessageEncryptionMechanismPolicy val compressionAlgorithmPolicy: CompressionAlgorithmPolicy val publicKeyAlgorithmPolicy: PublicKeyAlgorithmPolicy + val featurePolicy: FeaturePolicy = FeaturePolicy.defaultFeaturePolicy() val keyProtectionSettings: KeyRingProtectionSettings val notationRegistry: NotationRegistry val keyGenerationAlgorithmSuite: AlgorithmSuite @@ -717,4 +718,65 @@ class Policy { keyGenerationAlgorithmSuite) .apply { enableKeyParameterValidation = origin.enableKeyParameterValidation } } + + abstract class FeaturePolicy { + fun isAcceptable(feature: Byte): Boolean = + Feature.fromId(feature)?.let { isAcceptable(it) } ?: false + + abstract fun isAcceptable(feature: Feature): Boolean + + companion object { + @JvmStatic + fun defaultFeaturePolicy(): FeaturePolicy { + return whiteList( + listOf(Feature.MODIFICATION_DETECTION, Feature.MODIFICATION_DETECTION_2)) + } + + @JvmStatic + fun whiteList(whitelistedFeatures: List): FeaturePolicy { + return object : FeaturePolicy() { + override fun isAcceptable(feature: Feature): Boolean { + return whitelistedFeatures.contains(feature) + } + } + } + } + } + + companion object { + fun wildcardPolicy(): Policy = + Policy( + HashAlgorithmPolicy(HashAlgorithm.SHA512, HashAlgorithm.entries), + HashAlgorithmPolicy(HashAlgorithm.SHA512, HashAlgorithm.entries), + HashAlgorithmPolicy(HashAlgorithm.SHA512, HashAlgorithm.entries), + object : + MessageEncryptionMechanismPolicy( + SymmetricKeyAlgorithmPolicy( + SymmetricKeyAlgorithm.AES_256, SymmetricKeyAlgorithm.entries), + MessageEncryptionMechanism.integrityProtected( + SymmetricKeyAlgorithm.AES_256.algorithmId)) { + override fun isAcceptable( + encryptionMechanism: MessageEncryptionMechanism + ): Boolean { + return true + } + }, + object : + MessageEncryptionMechanismPolicy( + SymmetricKeyAlgorithmPolicy( + SymmetricKeyAlgorithm.AES_256, SymmetricKeyAlgorithm.entries), + MessageEncryptionMechanism.integrityProtected( + SymmetricKeyAlgorithm.AES_256.algorithmId)) { + override fun isAcceptable( + encryptionMechanism: MessageEncryptionMechanism + ): Boolean { + return true + } + }, + CompressionAlgorithmPolicy.anyCompressionAlgorithmPolicy(), + PublicKeyAlgorithmPolicy(PublicKeyAlgorithm.entries.associateWith { 0 }.toMap()), + KeyRingProtectionSettings.secureDefaultSettings(), + NotationRegistry(setOf()), + AlgorithmSuite.defaultAlgorithmSuite) + } } diff --git a/pgpainless-sop/src/main/kotlin/org/pgpainless/sop/UpdateKeyImpl.kt b/pgpainless-sop/src/main/kotlin/org/pgpainless/sop/UpdateKeyImpl.kt index c9f8e605..37767424 100644 --- a/pgpainless-sop/src/main/kotlin/org/pgpainless/sop/UpdateKeyImpl.kt +++ b/pgpainless-sop/src/main/kotlin/org/pgpainless/sop/UpdateKeyImpl.kt @@ -1,15 +1,17 @@ // SPDX-FileCopyrightText: 2025 Paul Schaub // -// SPDX-License-Identifier: CC0-1.0 +// SPDX-License-Identifier: Apache-2.0 package org.pgpainless.sop import java.io.InputStream import java.io.OutputStream +import java.util.* import org.bouncycastle.bcpg.KeyIdentifier import org.bouncycastle.openpgp.PGPSecretKeyRing import org.bouncycastle.openpgp.api.OpenPGPCertificate import org.pgpainless.PGPainless +import org.pgpainless.key.modification.secretkeyring.OpenPGPKeyUpdater import org.pgpainless.util.OpenPGPCertificateUtil import org.pgpainless.util.Passphrase import sop.Ready @@ -27,8 +29,9 @@ class UpdateKeyImpl(private val api: PGPainless) : UpdateKey { override fun key(key: InputStream): Ready { return object : Ready() { override fun writeTo(outputStream: OutputStream) { - val keyList = + var keyList = api.readKey().parseKeys(key).map { + // Merge keys if (mergeCerts[it.keyIdentifier] == null) { it } else { @@ -41,6 +44,15 @@ class UpdateKeyImpl(private val api: PGPainless) : UpdateKey { } } + // Update keys + keyList = + keyList.map { + OpenPGPKeyUpdater(it, protector, api) + .replaceRejectedAlgorithmPreferencesAndFeatures(addCapabilities) + .replaceWeakSubkeys(true, signingOnly) + .finish() + } + if (armor) { OpenPGPCertificateUtil.armor(keyList, outputStream) } else {