From 2952733a11cdc269421e87847d14d0e05a063255 Mon Sep 17 00:00:00 2001 From: Sebastian Stenzel Date: Wed, 28 Jul 2021 17:04:12 +0200 Subject: [PATCH] add PKCS12 support for on-demand creation and storage of an EC keypair --- pom.xml | 8 ++ src/main/java/module-info.java | 2 + .../ui/keyloading/hub/P12AccessHelper.java | 108 ++++++++++++++++++ .../ui/keyloading/hub/X509Helper.java | 81 +++++++++++++ src/main/resources/license/THIRD-PARTY.txt | 6 +- .../keyloading/hub/P12AccessHelperTest.java | 52 +++++++++ .../ui/keyloading/hub/X509HelperTest.java | 25 ++++ 7 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/cryptomator/ui/keyloading/hub/P12AccessHelper.java create mode 100644 src/main/java/org/cryptomator/ui/keyloading/hub/X509Helper.java create mode 100644 src/test/java/org/cryptomator/ui/keyloading/hub/P12AccessHelperTest.java create mode 100644 src/test/java/org/cryptomator/ui/keyloading/hub/X509HelperTest.java diff --git a/pom.xml b/pom.xml index 1369c5611..581a4c70c 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ 16 3.12.0 + 1.69 3.18.1 2.2 30.1.1-jre @@ -128,6 +129,13 @@ ${commons-lang3.version} + + + org.bouncycastle + bcpkix-jdk15on + ${bouncycastle.version} + + com.auth0 diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 6ba69a6ef..4caaf5592 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -23,6 +23,8 @@ module org.cryptomator.desktop { requires org.apache.commons.lang3; requires dagger; requires com.auth0.jwt; + requires org.bouncycastle.provider; + requires org.bouncycastle.pkix; /* TODO: filename-based modules: */ requires static javax.inject; /* ugly dagger/guava crap */ diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/P12AccessHelper.java b/src/main/java/org/cryptomator/ui/keyloading/hub/P12AccessHelper.java new file mode 100644 index 000000000..b25a7b188 --- /dev/null +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/P12AccessHelper.java @@ -0,0 +1,108 @@ +package org.cryptomator.ui.keyloading.hub; + +import org.cryptomator.cryptolib.api.InvalidPassphraseException; +import org.cryptomator.cryptolib.api.MasterkeyLoadingFailedException; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.GeneralSecurityException; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.UnrecoverableKeyException; +import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; + +class P12AccessHelper { + + private static final String EC_ALG = "EC"; + private static final String EC_CURVE_NAME = "secp256r1"; + private static final String SIGNATURE_ALG = "SHA256withECDSA"; + private static final String KEYSTORE_ALIAS_KEY = "key"; + private static final String KEYSTORE_ALIAS_CERT = "crt"; + + private P12AccessHelper() {} + + /** + * Creates a new key pair and stores it in PKCS#12 format at the given path. + * + * @param p12File The path of the .p12 file + * @param pw The password to protect the key material + * @throws IOException In case of I/O errors + * @throws MasterkeyLoadingFailedException If any cryptographic operation fails + */ + public static KeyPair createNew(Path p12File, char[] pw) throws IOException, MasterkeyLoadingFailedException { + try { + var keyPair = getKeyPairGenerator().generateKeyPair(); + var keyStore = getKeyStore(); + keyStore.load(null, pw); + var cert = X509Helper.createSelfSignedCert(keyPair, SIGNATURE_ALG); + var chain = new X509Certificate[]{cert}; + keyStore.setKeyEntry(KEYSTORE_ALIAS_KEY, keyPair.getPrivate(), pw, chain); + keyStore.setCertificateEntry(KEYSTORE_ALIAS_CERT, cert); + var tmpFile = p12File.resolveSibling(p12File.getFileName().toString() + ".tmp"); + try (var out = Files.newOutputStream(tmpFile, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) { + keyStore.store(out, pw); + } + Files.move(tmpFile, p12File, StandardCopyOption.REPLACE_EXISTING); + return keyPair; + } catch (GeneralSecurityException e) { + throw new MasterkeyLoadingFailedException("Failed to store PKCS12 file.", e); + } + } + + /** + * Loads a key pair from a PKCS#12 file located at the given path. + * + * @param p12File The path of the .p12 file + * @param pw The password to protect the key material + * @throws IOException In case of I/O errors + * @throws InvalidPassphraseException If the supplied password is incorrect + * @throws MasterkeyLoadingFailedException If any cryptographic operation fails + */ + public static KeyPair loadExisting(Path p12File, char[] pw) throws IOException, InvalidPassphraseException, MasterkeyLoadingFailedException { + try (var in = Files.newInputStream(p12File, StandardOpenOption.READ)) { + var keyStore = getKeyStore(); + keyStore.load(in, pw); + var sk = (PrivateKey) keyStore.getKey(KEYSTORE_ALIAS_KEY, pw); + var pk = keyStore.getCertificate(KEYSTORE_ALIAS_CERT).getPublicKey(); + return new KeyPair(pk, sk); + } catch (UnrecoverableKeyException e) { + throw new InvalidPassphraseException(); + } catch (IOException e) { + if (e.getCause() instanceof UnrecoverableKeyException) { + throw new InvalidPassphraseException(); + } else { + throw e; + } + } catch (GeneralSecurityException e) { + throw new MasterkeyLoadingFailedException("Failed to load PKCS12 file.", e); + } + } + + private static KeyPairGenerator getKeyPairGenerator() { + try { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance(EC_ALG); + keyGen.initialize(new ECGenParameterSpec(EC_CURVE_NAME)); + return keyGen; + } catch (NoSuchAlgorithmException | InvalidAlgorithmParameterException e) { + throw new IllegalStateException("secp256r1 curve not supported"); + } + } + + private static KeyStore getKeyStore() { + try { + return KeyStore.getInstance("PKCS12"); + } catch (KeyStoreException e) { + throw new IllegalStateException("Every implementation of the Java platform is required to support PKCS12."); + } + } + +} diff --git a/src/main/java/org/cryptomator/ui/keyloading/hub/X509Helper.java b/src/main/java/org/cryptomator/ui/keyloading/hub/X509Helper.java new file mode 100644 index 000000000..d9b7b953a --- /dev/null +++ b/src/main/java/org/cryptomator/ui/keyloading/hub/X509Helper.java @@ -0,0 +1,81 @@ +package org.cryptomator.ui.keyloading.hub; + +import org.bouncycastle.asn1.ASN1ObjectIdentifier; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.math.BigInteger; +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.sql.Date; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.UUID; + +class X509Helper { + + private static final X500Name ISSUER = new X500Name("CN=Cryptomator"); + private static final X500Name SUBJECT = new X500Name("CN=Self Signed Cert"); + private static final ASN1ObjectIdentifier ASN1_SUBJECT_KEY_ID = new ASN1ObjectIdentifier("2.5.29.14"); + + private X509Helper() {} + + /** + * Creates a self-signed X509Certificate containing the public key and signed with the private key of a given key pair. + * + * @param keyPair A key pair + * @param signatureAlg A signature algorithm suited for the given key pair (see available algorithms) + * @return A self-signed X509Certificate + * @throws CertificateException If certificate generation failed, e.g. because of unsupported algorithms + */ + public static X509Certificate createSelfSignedCert(KeyPair keyPair, String signatureAlg) throws CertificateException { + try { + X509v3CertificateBuilder certificateBuilder = new JcaX509v3CertificateBuilder( // + ISSUER, // + randomSerialNo(), // + Date.from(Instant.now()), // + Date.from(Instant.now().plus(3650, ChronoUnit.DAYS)), // + SUBJECT, // + keyPair.getPublic()); + certificateBuilder.addExtension(ASN1_SUBJECT_KEY_ID, false, getX509ExtensionUtils().createSubjectKeyIdentifier(keyPair.getPublic())); + var signer = new JcaContentSignerBuilder(signatureAlg).build(keyPair.getPrivate()); + var cert = certificateBuilder.build(signer); + try (InputStream in = new ByteArrayInputStream(cert.getEncoded())) { + return (X509Certificate) getCertFactory().generateCertificate(in); + } + } catch (IOException | OperatorCreationException e) { + throw new CertificateException(e); + } + } + + private static BigInteger randomSerialNo() { + return BigInteger.valueOf(UUID.randomUUID().getMostSignificantBits()); + } + + private static JcaX509ExtensionUtils getX509ExtensionUtils() { + try { + return new JcaX509ExtensionUtils(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("Every implementation of the Java platform is required to support SHA-1."); + } + } + + private static CertificateFactory getCertFactory() { + try { + return CertificateFactory.getInstance("X.509"); + } catch (CertificateException e) { + throw new IllegalStateException("Every implementation of the Java platform is required to support X.509."); + } + } + +} diff --git a/src/main/resources/license/THIRD-PARTY.txt b/src/main/resources/license/THIRD-PARTY.txt index 2b18cc3f1..e109eb172 100644 --- a/src/main/resources/license/THIRD-PARTY.txt +++ b/src/main/resources/license/THIRD-PARTY.txt @@ -11,7 +11,7 @@ GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/. -Cryptomator uses 40 third-party dependencies under the following licenses: +Cryptomator uses 43 third-party dependencies under the following licenses: Apache License v2.0: - jffi (com.github.jnr:jffi:1.2.23 - http://github.com/jnr/jffi) - jnr-a64asm (com.github.jnr:jnr-a64asm:1.0.0 - http://nexus.sonatype.org/oss-repository-hosting.html/jnr-a64asm) @@ -41,6 +41,10 @@ Cryptomator uses 40 third-party dependencies under the following licenses: - asm-commons (org.ow2.asm:asm-commons:7.1 - http://asm.ow2.org/) - asm-tree (org.ow2.asm:asm-tree:7.1 - http://asm.ow2.org/) - asm-util (org.ow2.asm:asm-util:7.1 - http://asm.ow2.org/) + Bouncy Castle Licence: + - Bouncy Castle PKIX, CMS, EAC, TSP, PKCS, OCSP, CMP, and CRMF APIs (org.bouncycastle:bcpkix-jdk15on:1.69 - https://www.bouncycastle.org/java.html) + - Bouncy Castle Provider (org.bouncycastle:bcprov-jdk15on:1.69 - https://www.bouncycastle.org/java.html) + - Bouncy Castle ASN.1 Extension and Utility APIs (org.bouncycastle:bcutil-jdk15on:1.69 - https://www.bouncycastle.org/java.html) Eclipse Public License - Version 1.0: - Jetty :: Servlet API and Schemas for JPMS and OSGi (org.eclipse.jetty.toolchain:jetty-servlet-api:4.0.6 - https://eclipse.org/jetty/jetty-servlet-api) Eclipse Public License - Version 2.0: diff --git a/src/test/java/org/cryptomator/ui/keyloading/hub/P12AccessHelperTest.java b/src/test/java/org/cryptomator/ui/keyloading/hub/P12AccessHelperTest.java new file mode 100644 index 000000000..a92eb40b3 --- /dev/null +++ b/src/test/java/org/cryptomator/ui/keyloading/hub/P12AccessHelperTest.java @@ -0,0 +1,52 @@ +package org.cryptomator.ui.keyloading.hub; + +import org.cryptomator.cryptolib.api.InvalidPassphraseException; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public class P12AccessHelperTest { + + @Test + public void testCreate(@TempDir Path tmpDir) throws IOException { + var p12File = tmpDir.resolve("test.p12"); + + var keyPair = P12AccessHelper.createNew(p12File, "asd".toCharArray()); + + Assertions.assertNotNull(keyPair); + Assertions.assertTrue(Files.exists(p12File)); + } + + @Nested + public class ExistingFile { + + private Path p12File; + + @BeforeEach + public void setup(@TempDir Path tmpDir) throws IOException { + p12File = tmpDir.resolve("test.p12"); + P12AccessHelper.createNew(p12File, "foo".toCharArray()); + } + + @Test + public void testLoadWithWrongPassword() { + Assertions.assertThrows(InvalidPassphraseException.class, () -> { + P12AccessHelper.loadExisting(p12File, "bar".toCharArray()); + }); + } + + @Test + public void testLoad() throws IOException { + var keyPair = P12AccessHelper.loadExisting(p12File, "foo".toCharArray()); + + Assertions.assertNotNull(keyPair); + } + } + +} \ No newline at end of file diff --git a/src/test/java/org/cryptomator/ui/keyloading/hub/X509HelperTest.java b/src/test/java/org/cryptomator/ui/keyloading/hub/X509HelperTest.java new file mode 100644 index 000000000..79abc82f4 --- /dev/null +++ b/src/test/java/org/cryptomator/ui/keyloading/hub/X509HelperTest.java @@ -0,0 +1,25 @@ +package org.cryptomator.ui.keyloading.hub; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.KeyPairGenerator; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.security.spec.ECGenParameterSpec; + +public class X509HelperTest { + + @Test + public void testCreateCert() throws NoSuchAlgorithmException, CertificateException, KeyStoreException, IOException, InvalidAlgorithmParameterException { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC"); + keyGen.initialize(new ECGenParameterSpec("secp256r1")); + var keyPair = keyGen.generateKeyPair(); + var cert = X509Helper.createSelfSignedCert(keyPair, "SHA256withECDSA"); + Assertions.assertNotNull(cert); + } + +} \ No newline at end of file