diff --git a/src/main/java/de/usd/cstchef/operations/encryption/RsaCipherBuilder.java b/src/main/java/de/usd/cstchef/operations/encryption/RsaCipherBuilder.java new file mode 100644 index 0000000..4c665b1 --- /dev/null +++ b/src/main/java/de/usd/cstchef/operations/encryption/RsaCipherBuilder.java @@ -0,0 +1,118 @@ +package de.usd.cstchef.operations.encryption; + +import java.security.Key; +import java.util.LinkedHashSet; + +import javax.crypto.Cipher; +import javax.crypto.spec.OAEPParameterSpec; +import javax.crypto.spec.PSource; + +import java.security.spec.MGF1ParameterSpec; + +/** + * Central place that knows how to turn a user selected RSA padding (and, for + * OAEP, the two message digests) into an initialised {@link Cipher}. + * + * Keeping this logic in one small class means the UI operations + * ({@code RsaEncryption} / {@code RsaDecryption}) only have to read their combo + * boxes and hand the values over. Supporting a new padding or digest in the + * future is a one line change to the constants below. + */ +public class RsaCipherBuilder { + + /** Selectable padding modes shown in the operations' "Padding" combo box. */ + public static final String PADDING_PKCS1 = "PKCS1Padding"; + public static final String PADDING_OAEP = "OAEPPadding"; + + public static final String[] PADDINGS = new String[] { + PADDING_PKCS1, + PADDING_OAEP + }; + + /** + * Message digests offered for the OAEP hash and the MGF1 hash. These are + * the digests SunJCE accepts inside an {@link OAEPParameterSpec}. Add new + * entries here to make them available in both RSA operations at once. + */ + public static final String[] DIGESTS = new String[] { + "SHA-1", + "SHA-224", + "SHA-256", + "SHA-384", + "SHA-512" + }; + + public static final String DEFAULT_DIGEST = "SHA-256"; + + /** + * Whether a padding name selects OAEP. Matches both the simple PEM choice + * ({@link #PADDING_OAEP}) and the provider's named variants used in KeyStore + * mode (e.g. {@code OAEPWITHSHA-256ANDMGF1PADDING}). + */ + public static boolean isOaep(String padding) { + return padding != null && padding.toUpperCase().contains("OAEP"); + } + + /** + * Build the padding list for KeyStore mode. We keep whatever the security + * provider advertises (so previously saved recipes still match) but make + * sure an OAEP option is always present. Some JVMs / Burp's bundled JRE + * advertise a reduced RSA padding list, which otherwise leaves the user + * with no way to pick OAEP even though we support it. + */ + public static String[] keyStorePaddings(String[] providerPaddings) { + LinkedHashSet paddings = new LinkedHashSet<>(); + if (providerPaddings != null) { + for (String padding : providerPaddings) { + if (padding != null && !padding.isEmpty()) { + paddings.add(padding); + } + } + } + + // Fall back to PKCS1 if the provider advertised nothing usable. + if (paddings.isEmpty()) { + paddings.add(PADDING_PKCS1); + } + + boolean hasOaep = false; + for (String padding : paddings) { + if (isOaep(padding)) { + hasOaep = true; + break; + } + } + if (!hasOaep) { + paddings.add(PADDING_OAEP); + } + + return paddings.toArray(new String[0]); + } + + /** + * Build and initialise an RSA {@link Cipher}. + * + * @param mode {@link Cipher#ENCRYPT_MODE} or {@link Cipher#DECRYPT_MODE} + * @param key the public (encrypt) or private (decrypt) key + * @param padding one of {@link #PADDINGS} + * @param oaepHash OAEP digest, one of {@link #DIGESTS} (ignored for PKCS1) + * @param mgf1Hash MGF1 digest, one of {@link #DIGESTS} (ignored for PKCS1) + */ + public static Cipher build(int mode, Key key, String padding, String oaepHash, String mgf1Hash) throws Exception { + if (PADDING_OAEP.equals(padding)) { + Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPPadding"); + OAEPParameterSpec spec = new OAEPParameterSpec( + oaepHash, + "MGF1", + new MGF1ParameterSpec(mgf1Hash), + PSource.PSpecified.DEFAULT); + cipher.init(mode, key, spec); + return cipher; + } + + // Default / backwards compatible behaviour: RSA with PKCS#1 v1.5 padding. + Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding"); + cipher.init(mode, key); + return cipher; + } +} diff --git a/src/main/java/de/usd/cstchef/operations/encryption/RsaDecryption.java b/src/main/java/de/usd/cstchef/operations/encryption/RsaDecryption.java index 28e0de9..4274654 100644 --- a/src/main/java/de/usd/cstchef/operations/encryption/RsaDecryption.java +++ b/src/main/java/de/usd/cstchef/operations/encryption/RsaDecryption.java @@ -1,5 +1,6 @@ package de.usd.cstchef.operations.encryption; +import java.awt.Component; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.io.BufferedReader; @@ -28,6 +29,10 @@ public class RsaDecryption extends KeystoreOperation { private VariableTextArea privateKeyTextArea; + private JComboBox pemPadding; + private JComboBox pemOaepHash; + private JComboBox pemMgf1Hash; + private JComboBox typeComboBox; private static String[] inOutModes = new String[] { "Raw", "Hex", "Base64" }; @@ -38,6 +43,8 @@ public class RsaDecryption extends KeystoreOperation { protected JComboBox inputMode; protected JComboBox outputMode; protected JComboBox paddings; + protected JComboBox ksOaepHash; + protected JComboBox ksMgf1Hash; private String lastSelection = "PEM"; @@ -74,8 +81,10 @@ protected ByteArray perform(ByteArray input) throws Exception { KeyFactory keyFactory = KeyFactory.getInstance("RSA"); RSAPrivateKey privateKey = (RSAPrivateKey) keyFactory.generatePrivate(keySpec); - Cipher cipher = Cipher.getInstance("RSA"); - cipher.init(Cipher.DECRYPT_MODE, privateKey); + Cipher cipher = RsaCipherBuilder.build(Cipher.DECRYPT_MODE, privateKey, + (String) pemPadding.getSelectedItem(), + (String) pemOaepHash.getSelectedItem(), + (String) pemMgf1Hash.getSelectedItem()); return factory.createByteArray(cipher.doFinal(input.getBytes())); } else if(typeComboBox.getSelectedItem().equals("KeyStore")) { @@ -83,8 +92,18 @@ else if(typeComboBox.getSelectedItem().equals("KeyStore")) { throw new IllegalArgumentException("No private key available."); String padding = (String)paddings.getSelectedItem(); - Cipher cipher = Cipher.getInstance(String.format("%s/%s/%s", algorithm, cipherMode, padding)); - cipher.init(Cipher.DECRYPT_MODE, this.selectedEntry.getPrivateKey()); + Cipher cipher; + if(RsaCipherBuilder.isOaep(padding)) { + // Named OAEP transformations (e.g. OAEPWITHSHA-256ANDMGF1PADDING) leave MGF1 at + // SHA-1 in SunJCE, so build an explicit OAEPParameterSpec with both chosen digests. + cipher = RsaCipherBuilder.build(Cipher.DECRYPT_MODE, this.selectedEntry.getPrivateKey(), + RsaCipherBuilder.PADDING_OAEP, + (String) ksOaepHash.getSelectedItem(), + (String) ksMgf1Hash.getSelectedItem()); + } else { + cipher = Cipher.getInstance(String.format("%s/%s/%s", algorithm, cipherMode, padding)); + cipher.init(Cipher.DECRYPT_MODE, this.selectedEntry.getPrivateKey()); + } String selectedInputMode = (String)inputMode.getSelectedItem(); String selectedOutputMode = (String)outputMode.getSelectedItem(); @@ -155,14 +174,35 @@ public void createMyUI() { CipherUtils utils = CipherUtils.getInstance(); CipherInfo info = utils.getCipherInfo(this.algorithm); - this.paddings = new JComboBox<>(info.getPaddings()); + this.paddings = new JComboBox<>(RsaCipherBuilder.keyStorePaddings(info.getPaddings())); this.addUIElement("Padding", this.paddings); + this.ksOaepHash = new JComboBox<>(RsaCipherBuilder.DIGESTS); + this.ksOaepHash.setSelectedItem(RsaCipherBuilder.DEFAULT_DIGEST); + this.addUIElement("OAEP Hash", this.ksOaepHash); + + this.ksMgf1Hash = new JComboBox<>(RsaCipherBuilder.DIGESTS); + this.ksMgf1Hash.setSelectedItem(RsaCipherBuilder.DEFAULT_DIGEST); + this.addUIElement("MGF1 Hash", this.ksMgf1Hash); + this.inputMode = new JComboBox<>(inOutModes); this.addUIElement("Input", this.inputMode); this.outputMode = new JComboBox<>(inOutModes); this.addUIElement("Output", this.outputMode); + + // The OAEP/MGF1 digests only apply to OAEP paddings, so only show them then. + this.paddings.addActionListener(e -> updateKeyStoreOaepVisibility()); + updateKeyStoreOaepVisibility(); + } + + private void updateKeyStoreOaepVisibility() { + boolean oaep = RsaCipherBuilder.isOaep((String) this.paddings.getSelectedItem()); + setRowVisible(this.ksOaepHash, oaep); + setRowVisible(this.ksMgf1Hash, oaep); + validate(); + repaint(); + updateStepPanel(); } private void clearUI() { @@ -182,6 +222,45 @@ private void clearUI() { private void createUIForPEM() { this.privateKeyTextArea = new VariableTextArea(); this.addUIElement("Private Key", this.privateKeyTextArea); + + this.pemPadding = new JComboBox<>(RsaCipherBuilder.PADDINGS); + this.addUIElement("Padding", this.pemPadding); + + this.pemOaepHash = new JComboBox<>(RsaCipherBuilder.DIGESTS); + this.pemOaepHash.setSelectedItem(RsaCipherBuilder.DEFAULT_DIGEST); + this.addUIElement("OAEP Hash", this.pemOaepHash); + + this.pemMgf1Hash = new JComboBox<>(RsaCipherBuilder.DIGESTS); + this.pemMgf1Hash.setSelectedItem(RsaCipherBuilder.DEFAULT_DIGEST); + this.addUIElement("MGF1 Hash", this.pemMgf1Hash); + + // The OAEP/MGF1 digests only apply to OAEP padding, so only show them then. + this.pemPadding.addActionListener(e -> updatePemOaepVisibility()); + updatePemOaepVisibility(); + } + + // Show the OAEP/MGF1 hash rows only while OAEP padding is selected. + private void updatePemOaepVisibility() { + boolean oaep = RsaCipherBuilder.PADDING_OAEP.equals(this.pemPadding.getSelectedItem()); + setRowVisible(this.pemOaepHash, oaep); + setRowVisible(this.pemMgf1Hash, oaep); + validate(); + repaint(); + updateStepPanel(); + } + + private void setRowVisible(Component comp, boolean visible) { + Component row = comp.getParent(); + Component[] comps = getContentBoxComponents(); + for (int i = 0; i < comps.length; i++) { + if (comps[i] == row) { + comps[i].setVisible(visible); + if (i + 1 < comps.length) { + comps[i + 1].setVisible(visible); // the spacing strut that follows the row + } + break; + } + } } public void updateStepPanel() { diff --git a/src/main/java/de/usd/cstchef/operations/encryption/RsaEncryption.java b/src/main/java/de/usd/cstchef/operations/encryption/RsaEncryption.java index 99d6f47..dd8f2db 100644 --- a/src/main/java/de/usd/cstchef/operations/encryption/RsaEncryption.java +++ b/src/main/java/de/usd/cstchef/operations/encryption/RsaEncryption.java @@ -14,6 +14,7 @@ import org.bouncycastle.util.encoders.Hex; +import java.awt.Component; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; @@ -29,6 +30,10 @@ public class RsaEncryption extends KeystoreOperation { private VariableTextArea publicKeyTextArea; + private JComboBox pemPadding; + private JComboBox pemOaepHash; + private JComboBox pemMgf1Hash; + private JComboBox typeComboBox; private static String[] inOutModes = new String[] { "Raw", "Hex", "Base64" }; @@ -39,6 +44,8 @@ public class RsaEncryption extends KeystoreOperation { protected JComboBox inputMode; protected JComboBox outputMode; protected JComboBox paddings; + protected JComboBox ksOaepHash; + protected JComboBox ksMgf1Hash; private String lastSelection = "PEM"; @@ -77,8 +84,10 @@ protected ByteArray perform(ByteArray input) throws Exception { KeyFactory keyFactory = KeyFactory.getInstance("RSA"); RSAPublicKey publicKey = (RSAPublicKey) keyFactory.generatePublic(keySpec); - Cipher cipher = Cipher.getInstance("RSA"); - cipher.init(Cipher.ENCRYPT_MODE, publicKey); + Cipher cipher = RsaCipherBuilder.build(Cipher.ENCRYPT_MODE, publicKey, + (String) pemPadding.getSelectedItem(), + (String) pemOaepHash.getSelectedItem(), + (String) pemMgf1Hash.getSelectedItem()); return factory.createByteArray(cipher.doFinal(input.getBytes())); } else if(typeComboBox.getSelectedItem().equals("KeyStore")) { @@ -86,8 +95,18 @@ else if(typeComboBox.getSelectedItem().equals("KeyStore")) { throw new IllegalArgumentException("No certificate available."); String padding = (String)paddings.getSelectedItem(); - Cipher cipher = Cipher.getInstance(String.format("%s/%s/%s", algorithm, cipherMode, padding)); - cipher.init(Cipher.ENCRYPT_MODE, this.cert.getPublicKey()); + Cipher cipher; + if(RsaCipherBuilder.isOaep(padding)) { + // Named OAEP transformations (e.g. OAEPWITHSHA-256ANDMGF1PADDING) leave MGF1 at + // SHA-1 in SunJCE, so build an explicit OAEPParameterSpec with both chosen digests. + cipher = RsaCipherBuilder.build(Cipher.ENCRYPT_MODE, this.cert.getPublicKey(), + RsaCipherBuilder.PADDING_OAEP, + (String) ksOaepHash.getSelectedItem(), + (String) ksMgf1Hash.getSelectedItem()); + } else { + cipher = Cipher.getInstance(String.format("%s/%s/%s", algorithm, cipherMode, padding)); + cipher.init(Cipher.ENCRYPT_MODE, this.cert.getPublicKey()); + } String selectedInputMode = (String)inputMode.getSelectedItem(); String selectedOutputMode = (String)outputMode.getSelectedItem(); @@ -158,14 +177,35 @@ public void createMyUI() { CipherUtils utils = CipherUtils.getInstance(); CipherInfo info = utils.getCipherInfo(this.algorithm); - this.paddings = new JComboBox<>(info.getPaddings()); + this.paddings = new JComboBox<>(RsaCipherBuilder.keyStorePaddings(info.getPaddings())); this.addUIElement("Padding", this.paddings); + this.ksOaepHash = new JComboBox<>(RsaCipherBuilder.DIGESTS); + this.ksOaepHash.setSelectedItem(RsaCipherBuilder.DEFAULT_DIGEST); + this.addUIElement("OAEP Hash", this.ksOaepHash); + + this.ksMgf1Hash = new JComboBox<>(RsaCipherBuilder.DIGESTS); + this.ksMgf1Hash.setSelectedItem(RsaCipherBuilder.DEFAULT_DIGEST); + this.addUIElement("MGF1 Hash", this.ksMgf1Hash); + this.inputMode = new JComboBox<>(inOutModes); this.addUIElement("Input", this.inputMode); this.outputMode = new JComboBox<>(inOutModes); this.addUIElement("Output", this.outputMode); + + // The OAEP/MGF1 digests only apply to OAEP paddings, so only show them then. + this.paddings.addActionListener(e -> updateKeyStoreOaepVisibility()); + updateKeyStoreOaepVisibility(); + } + + private void updateKeyStoreOaepVisibility() { + boolean oaep = RsaCipherBuilder.isOaep((String) this.paddings.getSelectedItem()); + setRowVisible(this.ksOaepHash, oaep); + setRowVisible(this.ksMgf1Hash, oaep); + validate(); + repaint(); + updateStepPanel(); } private void clearUI() { @@ -185,6 +225,45 @@ private void clearUI() { private void createUIForPEM() { this.publicKeyTextArea = new VariableTextArea(); this.addUIElement("Public Key", this.publicKeyTextArea); + + this.pemPadding = new JComboBox<>(RsaCipherBuilder.PADDINGS); + this.addUIElement("Padding", this.pemPadding); + + this.pemOaepHash = new JComboBox<>(RsaCipherBuilder.DIGESTS); + this.pemOaepHash.setSelectedItem(RsaCipherBuilder.DEFAULT_DIGEST); + this.addUIElement("OAEP Hash", this.pemOaepHash); + + this.pemMgf1Hash = new JComboBox<>(RsaCipherBuilder.DIGESTS); + this.pemMgf1Hash.setSelectedItem(RsaCipherBuilder.DEFAULT_DIGEST); + this.addUIElement("MGF1 Hash", this.pemMgf1Hash); + + // The OAEP/MGF1 digests only apply to OAEP padding, so only show them then. + this.pemPadding.addActionListener(e -> updatePemOaepVisibility()); + updatePemOaepVisibility(); + } + + // Show the OAEP/MGF1 hash rows only while OAEP padding is selected. + private void updatePemOaepVisibility() { + boolean oaep = RsaCipherBuilder.PADDING_OAEP.equals(this.pemPadding.getSelectedItem()); + setRowVisible(this.pemOaepHash, oaep); + setRowVisible(this.pemMgf1Hash, oaep); + validate(); + repaint(); + updateStepPanel(); + } + + private void setRowVisible(Component comp, boolean visible) { + Component row = comp.getParent(); + Component[] comps = getContentBoxComponents(); + for (int i = 0; i < comps.length; i++) { + if (comps[i] == row) { + comps[i].setVisible(visible); + if (i + 1 < comps.length) { + comps[i + 1].setVisible(visible); // the spacing strut that follows the row + } + break; + } + } } public void updateStepPanel() { diff --git a/src/test/java/de/usd/cstchef/operations/encryption/RsaCipherBuilderTest.java b/src/test/java/de/usd/cstchef/operations/encryption/RsaCipherBuilderTest.java new file mode 100644 index 0000000..e358667 --- /dev/null +++ b/src/test/java/de/usd/cstchef/operations/encryption/RsaCipherBuilderTest.java @@ -0,0 +1,111 @@ +package de.usd.cstchef.operations.encryption; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.util.Arrays; +import java.util.List; + +import javax.crypto.Cipher; + +import org.junit.Before; +import org.junit.Test; + +public class RsaCipherBuilderTest { + + private KeyPair keyPair; + + @Before + public void setUp() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + this.keyPair = generator.generateKeyPair(); + } + + private byte[] encrypt(byte[] plain, String padding, String oaepHash, String mgf1Hash) throws Exception { + Cipher cipher = RsaCipherBuilder.build(Cipher.ENCRYPT_MODE, keyPair.getPublic(), padding, oaepHash, mgf1Hash); + return cipher.doFinal(plain); + } + + private byte[] decrypt(byte[] enc, String padding, String oaepHash, String mgf1Hash) throws Exception { + Cipher cipher = RsaCipherBuilder.build(Cipher.DECRYPT_MODE, keyPair.getPrivate(), padding, oaepHash, mgf1Hash); + return cipher.doFinal(enc); + } + + // Reproduces the JS forge configuration: RSA-OAEP with SHA-256 for both the + // OAEP digest and the MGF1 digest. + @Test + public void oaepSha256RoundTrip() throws Exception { + byte[] plain = "Sup3rS3cr3tP@ssw0rd".getBytes("UTF-8"); + + byte[] enc = encrypt(plain, RsaCipherBuilder.PADDING_OAEP, "SHA-256", "SHA-256"); + byte[] dec = decrypt(enc, RsaCipherBuilder.PADDING_OAEP, "SHA-256", "SHA-256"); + + assertArrayEquals(plain, dec); + } + + // OAEP is randomised, so two encryptions of the same plaintext must differ. + @Test + public void oaepIsRandomised() throws Exception { + byte[] plain = "hello".getBytes("UTF-8"); + + byte[] enc1 = encrypt(plain, RsaCipherBuilder.PADDING_OAEP, "SHA-256", "SHA-256"); + byte[] enc2 = encrypt(plain, RsaCipherBuilder.PADDING_OAEP, "SHA-256", "SHA-256"); + + assertFalse(Arrays.equals(enc1, enc2)); + } + + // Independently selectable digests: SHA-1 OAEP digest with SHA-256 MGF1. + @Test + public void oaepMixedDigestsRoundTrip() throws Exception { + byte[] plain = "mixed-digests".getBytes("UTF-8"); + + byte[] enc = encrypt(plain, RsaCipherBuilder.PADDING_OAEP, "SHA-1", "SHA-256"); + byte[] dec = decrypt(enc, RsaCipherBuilder.PADDING_OAEP, "SHA-1", "SHA-256"); + + assertArrayEquals(plain, dec); + } + + // Backwards compatible default path still works. + @Test + public void pkcs1RoundTrip() throws Exception { + byte[] plain = "legacy".getBytes("UTF-8"); + + byte[] enc = encrypt(plain, RsaCipherBuilder.PADDING_PKCS1, null, null); + byte[] dec = decrypt(enc, RsaCipherBuilder.PADDING_PKCS1, null, null); + + assertArrayEquals(plain, dec); + } + + // A reduced provider list (no OAEP) must still yield an OAEP option. + @Test + public void keyStorePaddingsAddsOaepWhenMissing() { + List paddings = Arrays.asList( + RsaCipherBuilder.keyStorePaddings(new String[] { "PKCS1PADDING" })); + + assertTrue(paddings.contains("PKCS1PADDING")); + assertTrue(paddings.contains(RsaCipherBuilder.PADDING_OAEP)); + } + + // A provider that already advertises OAEP variants is left untouched (no duplicate generic entry). + @Test + public void keyStorePaddingsKeepsProviderOaep() { + List paddings = Arrays.asList(RsaCipherBuilder.keyStorePaddings( + new String[] { "NOPADDING", "PKCS1PADDING", "OAEPWITHSHA-256ANDMGF1PADDING" })); + + assertTrue(paddings.contains("OAEPWITHSHA-256ANDMGF1PADDING")); + assertFalse(paddings.contains(RsaCipherBuilder.PADDING_OAEP)); + } + + // An empty provider list still produces usable choices. + @Test + public void keyStorePaddingsHandlesEmpty() { + List paddings = Arrays.asList(RsaCipherBuilder.keyStorePaddings(new String[0])); + + assertTrue(paddings.contains(RsaCipherBuilder.PADDING_PKCS1)); + assertTrue(paddings.contains(RsaCipherBuilder.PADDING_OAEP)); + } +}