diff --git a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/ConsumerOptions.java b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/ConsumerOptions.java index d57eff48..8f0576ee 100644 --- a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/ConsumerOptions.java +++ b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/ConsumerOptions.java @@ -22,6 +22,7 @@ import org.bouncycastle.openpgp.PGPPublicKeyRingCollection; import org.bouncycastle.openpgp.PGPSecretKeyRing; import org.bouncycastle.openpgp.PGPSecretKeyRingCollection; import org.bouncycastle.openpgp.PGPSignature; +import org.bouncycastle.openpgp.operator.PublicKeyDataDecryptorFactory; import org.pgpainless.decryption_verification.cleartext_signatures.InMemoryMultiPassStrategy; import org.pgpainless.decryption_verification.cleartext_signatures.MultiPassStrategy; import org.pgpainless.key.protection.SecretKeyRingProtector; @@ -48,7 +49,7 @@ public class ConsumerOptions { // Session key for decryption without passphrase/key private SessionKey sessionKey = null; - private HardwareSecurity.DecryptionCallback hardwareDecryptionCallback = null; + private Map, PublicKeyDataDecryptorFactory> customPublicKeyDataDecryptorFactories = new HashMap<>(); private final Map decryptionKeys = new HashMap<>(); private final Set decryptionPassphrases = new HashSet<>(); @@ -239,11 +240,28 @@ public class ConsumerOptions { return this; } - public ConsumerOptions setHardwareDecryptionCallback(HardwareSecurity.DecryptionCallback callback) { - this.hardwareDecryptionCallback = callback; + /** + * Add a custom {@link PublicKeyDataDecryptorFactory} which enable decryption of messages, e.g. using + * hardware-backed secret keys. + * (See e.g. {@link org.pgpainless.decryption_verification.HardwareSecurity.HardwareDataDecryptorFactory}). + * + * The set of key-ids determines, whether the decryptor factory shall be consulted to decrypt a given session key. + * See for example {@link HardwareSecurity#getIdsOfHardwareBackedKeys(PGPSecretKeyRing)}. + * + * @param keyIds set of key-ids for which the factory shall be consulted + * @param decryptorFactory decryptor factory + * @return options + */ + public ConsumerOptions addCustomDecryptorFactory( + @Nonnull Set keyIds, @Nonnull PublicKeyDataDecryptorFactory decryptorFactory) { + this.customPublicKeyDataDecryptorFactories.put(keyIds, decryptorFactory); return this; } + Map, PublicKeyDataDecryptorFactory> getCustomDecryptorFactories() { + return new HashMap<>(customPublicKeyDataDecryptorFactories); + } + public @Nonnull Set getDecryptionKeys() { return Collections.unmodifiableSet(decryptionKeys.keySet()); } diff --git a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/DecryptionStreamFactory.java b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/DecryptionStreamFactory.java index 0739eb19..ba837940 100644 --- a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/DecryptionStreamFactory.java +++ b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/DecryptionStreamFactory.java @@ -408,6 +408,34 @@ public final class DecryptionStreamFactory { } } + // Try custom PublicKeyDataDecryptorFactories (e.g. hardware-backed). + Map, PublicKeyDataDecryptorFactory> customFactories = options.getCustomDecryptorFactories(); + for (PGPPublicKeyEncryptedData publicKeyEncryptedData : publicKeyProtected) { + Long keyId = publicKeyEncryptedData.getKeyID(); + for (Set keyIds : customFactories.keySet()) { + if (!keyIds.contains(keyId)) { + continue; + } + + PublicKeyDataDecryptorFactory decryptorFactory = customFactories.get(keyIds); + try { + InputStream decryptedDataStream = publicKeyEncryptedData.getDataStream(decryptorFactory); + PGPSessionKey pgpSessionKey = publicKeyEncryptedData.getSessionKey(decryptorFactory); + SessionKey sessionKey = new SessionKey(pgpSessionKey); + resultBuilder.setSessionKey(sessionKey); + + throwIfAlgorithmIsRejected(sessionKey.getAlgorithm()); + + integrityProtectedEncryptedInputStream = + new IntegrityProtectedInputStream(decryptedDataStream, publicKeyEncryptedData, options); + + return integrityProtectedEncryptedInputStream; + } catch (PGPException e) { + LOGGER.debug("Decryption with custom PublicKeyDataDecryptorFactory failed", e); + } + } + } + // Then try decryption with public key encryption for (PGPPublicKeyEncryptedData publicKeyEncryptedData : publicKeyProtected) { PGPPrivateKey privateKey = null; diff --git a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/HardwareSecurity.java b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/HardwareSecurity.java index bea14aef..cc8cf598 100644 --- a/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/HardwareSecurity.java +++ b/pgpainless-core/src/main/java/org/pgpainless/decryption_verification/HardwareSecurity.java @@ -1,8 +1,23 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + package org.pgpainless.decryption_verification; -import org.bouncycastle.openpgp.PGPPublicKeyEncryptedData; -import org.pgpainless.util.SessionKey; +import org.bouncycastle.bcpg.S2K; +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPSecretKey; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.operator.PGPDataDecryptor; +import org.bouncycastle.openpgp.operator.PublicKeyDataDecryptorFactory; +import org.bouncycastle.openpgp.operator.bc.BcPublicKeyDataDecryptorFactory; +import java.util.HashSet; +import java.util.Set; + +/** + * Enable integration of hardware-backed OpenPGP keys. + */ public class HardwareSecurity { public interface DecryptionCallback { @@ -13,15 +28,81 @@ public class HardwareSecurity { * * If decryption fails for some reason, a subclass of the {@link HardwareSecurityException} is thrown. * - * @param pkesk public-key-encrypted session key + * @param keyAlgorithm algorithm + * @param sessionKeyData encrypted session key + * * @return decrypted session key * @throws HardwareSecurityException exception */ - SessionKey decryptSessionKey(PGPPublicKeyEncryptedData pkesk) throws HardwareSecurityException; + byte[] decryptSessionKey(int keyAlgorithm, byte[] sessionKeyData) + throws HardwareSecurityException; } - public static class HardwareSecurityException extends Exception { + /** + * Return the key-ids of all keys which appear to be stored on a hardware token / smartcard. + * + * @param secretKeys secret keys + * @return set of keys with S2K type DIVERT_TO_CARD or GNU_DUMMY_S2K + */ + public static Set getIdsOfHardwareBackedKeys(PGPSecretKeyRing secretKeys) { + Set hardwareBackedKeys = new HashSet<>(); + for (PGPSecretKey secretKey : secretKeys) { + S2K s2K = secretKey.getS2K(); + if (s2K == null) { + continue; + } + + int type = s2K.getType(); + // TODO: Is GNU_DUMMY_S2K appropriate? + if (type == S2K.GNU_PROTECTION_MODE_DIVERT_TO_CARD || type == S2K.GNU_DUMMY_S2K) { + hardwareBackedKeys.add(secretKey.getKeyID()); + } + } + return hardwareBackedKeys; + } + + /** + * Implementation of {@link PublicKeyDataDecryptorFactory} which delegates decryption of encrypted session keys + * to a {@link DecryptionCallback}. + * Users can provide such a callback to delegate decryption of messages to hardware security SDKs. + */ + public static class HardwareDataDecryptorFactory implements PublicKeyDataDecryptorFactory { + + private final DecryptionCallback callback; + // luckily we can instantiate the BcPublicKeyDataDecryptorFactory with null as argument. + private final PublicKeyDataDecryptorFactory factory = + new BcPublicKeyDataDecryptorFactory(null); + + /** + * Create a new {@link HardwareDataDecryptorFactory}. + * + * @param callback decryption callback + */ + public HardwareDataDecryptorFactory(DecryptionCallback callback) { + this.callback = callback; + } + + @Override + public byte[] recoverSessionData(int keyAlgorithm, byte[][] secKeyData) + throws PGPException { + try { + // delegate decryption to the callback + return callback.decryptSessionKey(keyAlgorithm, secKeyData[0]); + } catch (HardwareSecurityException e) { + throw new PGPException("Hardware-backed decryption failed.", e); + } + } + + @Override + public PGPDataDecryptor createDataDecryptor(boolean withIntegrityPacket, int encAlgorithm, byte[] key) + throws PGPException { + return factory.createDataDecryptor(withIntegrityPacket, encAlgorithm, key); + } + } + + public static class HardwareSecurityException + extends Exception { } } diff --git a/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/CustomPublicKeyDataDecryptorFactoryTest.java b/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/CustomPublicKeyDataDecryptorFactoryTest.java new file mode 100644 index 00000000..f507e02e --- /dev/null +++ b/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/CustomPublicKeyDataDecryptorFactoryTest.java @@ -0,0 +1,86 @@ +// SPDX-FileCopyrightText: 2022 Paul Schaub +// +// SPDX-License-Identifier: Apache-2.0 + +package org.pgpainless.decryption_verification; + +import org.bouncycastle.openpgp.PGPException; +import org.bouncycastle.openpgp.PGPPrivateKey; +import org.bouncycastle.openpgp.PGPPublicKey; +import org.bouncycastle.openpgp.PGPPublicKeyRing; +import org.bouncycastle.openpgp.PGPSecretKey; +import org.bouncycastle.openpgp.PGPSecretKeyRing; +import org.bouncycastle.openpgp.operator.PublicKeyDataDecryptorFactory; +import org.bouncycastle.openpgp.operator.bc.BcPublicKeyDataDecryptorFactory; +import org.bouncycastle.util.io.Streams; +import org.junit.jupiter.api.Test; +import org.pgpainless.PGPainless; +import org.pgpainless.algorithm.EncryptionPurpose; +import org.pgpainless.encryption_signing.EncryptionOptions; +import org.pgpainless.encryption_signing.EncryptionStream; +import org.pgpainless.encryption_signing.ProducerOptions; +import org.pgpainless.key.info.KeyRingInfo; +import org.pgpainless.key.protection.UnlockSecretKey; +import org.pgpainless.util.Passphrase; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidAlgorithmParameterException; +import java.security.NoSuchAlgorithmException; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class CustomPublicKeyDataDecryptorFactoryTest { + + @Test + public void testDecryptionWithEmulatedHardwareDecryptionCallback() + throws PGPException, IOException, InvalidAlgorithmParameterException, NoSuchAlgorithmException { + PGPSecretKeyRing secretKey = PGPainless.generateKeyRing().modernKeyRing("Alice"); + PGPPublicKeyRing cert = PGPainless.extractCertificate(secretKey); + KeyRingInfo info = PGPainless.inspectKeyRing(secretKey); + PGPPublicKey encryptionKey = info.getEncryptionSubkeys(EncryptionPurpose.ANY).get(0); + + // Encrypt a test message + String plaintext = "Hello, World!\n"; + ByteArrayOutputStream ciphertextOut = new ByteArrayOutputStream(); + EncryptionStream encryptionStream = PGPainless.encryptAndOrSign() + .onOutputStream(ciphertextOut) + .withOptions(ProducerOptions.encrypt(EncryptionOptions.get() + .addRecipient(cert))); + encryptionStream.write(plaintext.getBytes(StandardCharsets.UTF_8)); + encryptionStream.close(); + + HardwareSecurity.DecryptionCallback hardwareDecryptionCallback = new HardwareSecurity.DecryptionCallback() { + @Override + public byte[] decryptSessionKey(int keyAlgorithm, byte[] sessionKeyData) + throws HardwareSecurity.HardwareSecurityException { + // Emulate hardware decryption. + try { + PGPSecretKey decryptionKey = secretKey.getSecretKey(encryptionKey.getKeyID()); + PGPPrivateKey privateKey = UnlockSecretKey.unlockSecretKey(decryptionKey, Passphrase.emptyPassphrase()); + PublicKeyDataDecryptorFactory internal = new BcPublicKeyDataDecryptorFactory(privateKey); + return internal.recoverSessionData(keyAlgorithm, new byte[][] {sessionKeyData}); + } catch (PGPException e) { + throw new HardwareSecurity.HardwareSecurityException(); + } + } + }; + + // Decrypt + DecryptionStream decryptionStream = PGPainless.decryptAndOrVerify() + .onInputStream(new ByteArrayInputStream(ciphertextOut.toByteArray())) + .withOptions(ConsumerOptions.get() + .addCustomDecryptorFactory( + Collections.singleton(encryptionKey.getKeyID()), + new HardwareSecurity.HardwareDataDecryptorFactory(hardwareDecryptionCallback))); + + ByteArrayOutputStream decryptedOut = new ByteArrayOutputStream(); + Streams.pipeAll(decryptionStream, decryptedOut); + decryptionStream.close(); + + assertEquals(plaintext, decryptedOut.toString()); + } +} diff --git a/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/HardwareSecurityCallbackTest.java b/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/HardwareSecurityCallbackTest.java deleted file mode 100644 index d731277e..00000000 --- a/pgpainless-core/src/test/java/org/pgpainless/decryption_verification/HardwareSecurityCallbackTest.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.pgpainless.decryption_verification; - -import org.bouncycastle.openpgp.PGPException; -import org.bouncycastle.openpgp.PGPPublicKeyEncryptedData; -import org.junit.jupiter.api.Test; -import org.pgpainless.PGPainless; -import org.pgpainless.util.SessionKey; - -import java.io.ByteArrayInputStream; -import java.io.IOException; - -public class HardwareSecurityCallbackTest { - - @Test - public void test() throws PGPException, IOException { - PGPainless.decryptAndOrVerify() - .onInputStream(new ByteArrayInputStream(new byte[0])) - .withOptions(ConsumerOptions.get() - .setHardwareDecryptionCallback(new HardwareSecurity.DecryptionCallback() { - @Override - public SessionKey decryptSessionKey(PGPPublicKeyEncryptedData pkesk) throws HardwareSecurity.HardwareSecurityException { - /* - pkesk.getSessionKey(new PublicKeyDataDecryptorFactory() { - @Override - public byte[] recoverSessionData(int keyAlgorithm, byte[][] secKeyData) throws PGPException { - return new byte[0]; - } - - @Override - public PGPDataDecryptor createDataDecryptor(boolean withIntegrityPacket, int encAlgorithm, byte[] key) throws PGPException { - return null; - } - }); - */ - return null; - } - })); - } -}