diff --git a/pom.xml b/pom.xml index f60ed1b70..cbb2f1971 100644 --- a/pom.xml +++ b/pom.xml @@ -88,6 +88,7 @@ io.jenkins.tools.bom bom-2.426.x + 2961.v1f472390972e import pom @@ -105,7 +106,11 @@ configuration-as-code true - + + org.jenkins-ci.plugins + bouncycastle-api + 2.30.1.78.1-246.ve1089fe22055 + org.mockito diff --git a/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl.java b/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl.java index b87577de6..00121cf79 100644 --- a/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl.java +++ b/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl.java @@ -30,6 +30,7 @@ import edu.umd.cs.findbugs.annotations.NonNull; import edu.umd.cs.findbugs.annotations.Nullable; import hudson.Extension; +import hudson.RelativePath; import hudson.Util; import hudson.model.AbstractDescribableImpl; import hudson.model.Descriptor; @@ -41,18 +42,32 @@ import java.io.ObjectStreamException; import java.io.Serializable; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.KeyStoreException; import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; import java.security.UnrecoverableEntryException; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.security.interfaces.DSAPrivateKey; +import java.security.interfaces.ECPrivateKey; +import java.security.interfaces.RSAKey; +import java.security.interfaces.RSAPrivateKey; import java.util.Arrays; import java.util.Base64; import java.util.Enumeration; +import java.util.List; import java.util.Objects; import java.util.logging.Level; import java.util.logging.LogRecord; import java.util.logging.Logger; +import java.util.stream.Collectors; +import javax.crypto.interfaces.DHPrivateKey; +import javax.security.auth.DestroyFailedException; +import jenkins.bouncycastle.api.PEMEncodable; import jenkins.model.Jenkins; import jenkins.security.FIPS140; import net.jcip.annotations.GuardedBy; @@ -64,6 +79,7 @@ import org.kohsuke.stapler.DataBoundConstructor; import org.kohsuke.stapler.QueryParameter; import org.kohsuke.stapler.interceptor.RequirePOST; +import org.kohsuke.stapler.verb.POST; public class CertificateCredentialsImpl extends BaseStandardCredentials implements StandardCertificateCredentials { @@ -118,6 +134,13 @@ public CertificateCredentialsImpl(@CheckForNull CredentialsScope scope, Objects.requireNonNull(keyStoreSource); this.password = Secret.fromString(password); this.keyStoreSource = keyStoreSource; + // ensure the keySore is valid + // we check here as otherwise it will lead to hard to diagnose errors when used + try { + keyStoreSource.toKeyStore(toCharArray(this.password)); + } catch (GeneralSecurityException | IOException e) { + throw new IllegalArgumentException("KeyStore is not valid.", e); + } } /** @@ -137,23 +160,25 @@ private static char[] toCharArray(@NonNull Secret password) { * * @return the {@link KeyStore} containing the certificate. */ + @Override @NonNull public synchronized KeyStore getKeyStore() { long lastModified = keyStoreSource.getKeyStoreLastModified(); if (keyStore == null || keyStoreLastModified < lastModified) { KeyStore keyStore; try { - keyStore = KeyStore.getInstance("PKCS12"); - } catch (KeyStoreException e) { - throw new IllegalStateException("PKCS12 is a keystore type per the JLS spec", e); - } - try { - keyStore.load(new ByteArrayInputStream(keyStoreSource.getKeyStoreBytes()), toCharArray(password)); - } catch (CertificateException | NoSuchAlgorithmException | IOException e) { + keyStore = keyStoreSource.toKeyStore(toCharArray(password)); + } catch (GeneralSecurityException | IOException e) { LogRecord lr = new LogRecord(Level.WARNING, "Credentials ID {0}: Could not load keystore from {1}"); lr.setParameters(new Object[]{getId(), keyStoreSource}); lr.setThrown(e); LOGGER.log(lr); + // provide an empty uninitialised KeyStore for consumers + try { + keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + } catch (KeyStoreException e2) { + throw new IllegalStateException("JVM can not create a KeyStore of the JVM Default Type ("+ KeyStore.getDefaultType() +")", e2); + } } this.keyStore = keyStore; this.keyStoreLastModified = lastModified; @@ -166,6 +191,7 @@ public synchronized KeyStore getKeyStore() { * * @return the password used to protect the certificate's private key in {@link #getKeyStore()}. */ + @Override @NonNull public Secret getPassword() { return password; @@ -212,6 +238,23 @@ public String getDisplayName() { public String getIconClassName() { return "icon-application-certificate"; } + + @Restricted(NoExternalUse.class) + @POST + public FormValidation doCheckPassword(@QueryParameter String value) { + Secret s = Secret.fromString(value); + String pw = s.getPlainText(); + if (FIPS140.useCompliantAlgorithms() && pw.length() < 14) { + return FormValidation.error(Messages.CertificateCredentialsImpl_ShortPasswordFIPS()); + } + if (pw.isEmpty()) { + return FormValidation.warning(Messages.CertificateCredentialsImpl_NoPassword()); + } + if (pw.length() < 14) { + return FormValidation.warning(Messages.CertificateCredentialsImpl_ShortPassword()); + } + return FormValidation.ok(); + } } /** @@ -220,12 +263,16 @@ public String getIconClassName() { public static abstract class KeyStoreSource extends AbstractDescribableImpl { /** - * Returns the {@code byte[]} content of the {@link KeyStore}. - * - * @return the {@code byte[]} content of the {@link KeyStore}. + * @deprecated code should neither implement nor call this. + * This is an internal representation of a KeyStore and use of this internal representation would require knowledge of the keystore type. + * @see #toKeyStore(char[]) + * @throws IllegalStateException always */ @NonNull - public abstract byte[] getKeyStoreBytes(); + @Deprecated(forRemoval = true) + public byte[] getKeyStoreBytes() { + throw new IllegalStateException("Callers should use toKeyStore"); + } /** * Returns a {@link System#currentTimeMillis()} comparable timestamp of when the content was last modified. @@ -236,6 +283,16 @@ public static abstract class KeyStoreSource extends AbstractDescribableImpl { - /** - * {@inheritDoc} - */ + protected static FormValidation validateCertificateKeystore(KeyStore keyStore, char[] passwordChars) + throws KeyStoreException, NoSuchAlgorithmException { + int size = keyStore.size(); + if (size == 0) { + return FormValidation.warning(Messages.CertificateCredentialsImpl_EmptyKeystore()); + } + StringBuilder buf = new StringBuilder(); + boolean first = true; + for (Enumeration enumeration = keyStore.aliases(); enumeration.hasMoreElements(); ) { + String alias = enumeration.nextElement(); + if (first) { + first = false; + } else { + buf.append(", "); + } + buf.append(alias); + if (keyStore.isCertificateEntry(alias)) { + keyStore.getCertificate(alias); + } else if (keyStore.isKeyEntry(alias)) { + if (passwordChars == null) { + return FormValidation.warning( + Messages.CertificateCredentialsImpl_LoadKeyFailedQueryEmptyPassword(alias)); + } + try { + keyStore.getKey(alias, passwordChars); + } catch (UnrecoverableEntryException e) { + return FormValidation.warning(e, + Messages.CertificateCredentialsImpl_LoadKeyFailed(alias)); + } + } + } + return FormValidation.ok(StringUtils + .defaultIfEmpty(StandardCertificateCredentials.NameProvider.getSubjectDN(keyStore), + buf.toString())); + } + protected KeyStoreSourceDescriptor() { super(); } - /** - * {@inheritDoc} - */ protected KeyStoreSourceDescriptor(Class clazz) { super(clazz); } - /** - * Helper method that performs form validation on a {@link KeyStore}. - * - * @param type the type of keystore to instantiate, see {@link KeyStore#getInstance(String)}. - * @param keystoreBytes the {@code byte[]} content of the {@link KeyStore}. - * @param password the password to use when loading the {@link KeyStore} and recovering the key from the - * {@link KeyStore}. - * @return the validation results. - */ - @NonNull - protected static FormValidation validateCertificateKeystore(String type, byte[] keystoreBytes, - String password) { - - if (keystoreBytes == null || keystoreBytes.length == 0) { - return FormValidation.warning(Messages.CertificateCredentialsImpl_LoadKeystoreFailed()); - } - - char[] passwordChars = toCharArray(Secret.fromString(password)); - try { - KeyStore keyStore = KeyStore.getInstance(type); - keyStore.load(new ByteArrayInputStream(keystoreBytes), passwordChars); - int size = keyStore.size(); - if (size == 0) { - return FormValidation.warning(Messages.CertificateCredentialsImpl_EmptyKeystore()); - } - StringBuilder buf = new StringBuilder(); - boolean first = true; - for (Enumeration enumeration = keyStore.aliases(); enumeration.hasMoreElements(); ) { - String alias = enumeration.nextElement(); - if (first) { - first = false; - } else { - buf.append(", "); - } - buf.append(alias); - if (keyStore.isCertificateEntry(alias)) { - keyStore.getCertificate(alias); - } else if (keyStore.isKeyEntry(alias)) { - if (passwordChars == null) { - return FormValidation.warning( - Messages.CertificateCredentialsImpl_LoadKeyFailedQueryEmptyPassword(alias)); - } - try { - keyStore.getKey(alias, passwordChars); - } catch (UnrecoverableEntryException e) { - return FormValidation.warning(e, - Messages.CertificateCredentialsImpl_LoadKeyFailed(alias)); - } - } - } - return FormValidation.ok(StringUtils - .defaultIfEmpty(StandardCertificateCredentials.NameProvider.getSubjectDN(keyStore), - buf.toString())); - } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | IOException e) { - return FormValidation.warning(e, Messages.CertificateCredentialsImpl_LoadKeystoreFailed()); - } finally { - if (passwordChars != null) { - Arrays.fill(passwordChars, ' '); - } - } - } } /** @@ -450,6 +476,21 @@ public boolean isSnapshotSource() { return true; } + @Override + public KeyStore toKeyStore(char[] password) throws NoSuchAlgorithmException, CertificateException, KeyStoreException, KeyStoreException, IOException { + if (FIPS140.useCompliantAlgorithms()) { + Class self = this.getClass(); + String className = self.getName(); + String pluginName = Jenkins.get().getPluginManager().whichPlugin(self).getShortName(); + throw new IllegalStateException(className + " is not FIPS compliant and can not be used when Jenkins is in FIPS mode. " + + "An issue should be filed against the plugin " + pluginName + " to ensure it is adapted to be able to work in this mode"); + } + // legacy behaviour that assumed all KeyStoreSources where in the non compliant PKCS12 format + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(new ByteArrayInputStream(getKeyStoreBytes()), password); + return keyStore; + } + /** * {@inheritDoc} */ @@ -468,9 +509,6 @@ private static void ensureNotRunningInFIPSMode() { } } - /** - * {@inheritDoc} - */ public static class DescriptorImpl extends KeyStoreSourceDescriptor { public static final String DEFAULT_VALUE = UploadedKeyStoreSource.class.getName() + ".default-value"; @@ -479,7 +517,7 @@ public static class DescriptorImpl extends KeyStoreSourceDescriptor { */ @Restricted(NoExternalUse.class) @Extension - public static DescriptorImpl extension() { + public static KeyStoreSourceDescriptor extension() { return FIPS140.useCompliantAlgorithms() ? null : new DescriptorImpl(); } @@ -542,7 +580,7 @@ public FormValidation doCheckUploadedKeystore(@QueryParameter String value, // Priority for the file, to cover the (re-)upload cases if (StringUtils.isNotEmpty(uploadedCertFile)) { byte[] uploadedCertFileBytes = Base64.getDecoder().decode(uploadedCertFile.getBytes(StandardCharsets.UTF_8)); - return validateCertificateKeystore("PKCS12", uploadedCertFileBytes, password); + return validateCertificateKeystore(uploadedCertFileBytes, password); } if (StringUtils.isBlank(value)) { @@ -558,8 +596,223 @@ public FormValidation doCheckUploadedKeystore(@QueryParameter String value, if (keystoreBytes == null || keystoreBytes.length == 0) { return FormValidation.error(Messages.CertificateCredentialsImpl_LoadKeystoreFailed()); } + return validateCertificateKeystore(keystoreBytes, password); + } + + /** + * Helper method that performs form validation on a {@link KeyStore}. + * + * @param keystoreBytes the {@code byte[]} content of the {@link KeyStore}. + * @param password the password to use when loading the {@link KeyStore} and recovering the key from the + * {@link KeyStore}. + * @return the validation results. + */ + @NonNull + protected static FormValidation validateCertificateKeystore(byte[] keystoreBytes, + String password) { + + ensureNotRunningInFIPSMode(); + if (keystoreBytes == null || keystoreBytes.length == 0) { + return FormValidation.warning(Messages.CertificateCredentialsImpl_LoadKeystoreFailed()); + } + + char[] passwordChars = toCharArray(Secret.fromString(password)); + try { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(new ByteArrayInputStream(keystoreBytes), passwordChars); + return validateCertificateKeystore(keyStore, passwordChars); + } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | IOException e) { + return FormValidation.warning(e, Messages.CertificateCredentialsImpl_LoadKeystoreFailed()); + } finally { + if (passwordChars != null) { + Arrays.fill(passwordChars, ' '); + } + } + } + + } + } + + /** + * A user entered PEM encoded certificate chain and key. + */ + public static class PEMEntryKeyStoreSource extends KeyStoreSource implements Serializable { + + private static final long serialVersionUID = 1L; - return validateCertificateKeystore("PKCS12", keystoreBytes, password); + /** The chain of certificates encoded as multiple PEM objects*/ + private final Secret certChain; + /** The PEM encoded (and possibly encrypted) secret key */ + private final Secret privateKey; + + /** + * Constructor able to receive file directly + * + * @param certChain the PEM encoded certificate chain (possibly encrypted as a secret) + * @param privateKey the PEM encoded and possibly encrypted key for the certificate (possibly encrypted as a secret) + */ + @SuppressWarnings("unused") // by stapler + @DataBoundConstructor + public PEMEntryKeyStoreSource(String certChain, String privateKey) { + this.certChain = Secret.fromString(certChain); + this.privateKey = Secret.fromString(privateKey); + } + + /** + * Returns the PEM encoded certificate chain. + */ + @Restricted(NoExternalUse.class) // for jelly only + public Secret getCertChain() { + return certChain; + } + + /** + * Returns the PEM encoded private key. + */ + @Restricted(NoExternalUse.class) // for jelly only + public Secret getPrivateKey() { + return privateKey; + } + + /** + * {@inheritDoc} + */ + @Override + public long getKeyStoreLastModified() { + return 0L; // our content is final so it will never change + } + + /** + * {@inheritDoc} + */ + @Override + public boolean isSnapshotSource() { + return true; + } + + @Override + public KeyStore toKeyStore(char[] password) throws NoSuchAlgorithmException, CertificateException, KeyStoreException, KeyStoreException, UnrecoverableKeyException, IOException { + return toKeyStore(certChain.getPlainText(), privateKey.getPlainText(), password); + } + + protected static KeyStore toKeyStore(String pemEncodedCerts, String pemEncodedKey, char[] password) throws NoSuchAlgorithmException, CertificateException, KeyStoreException, KeyStoreException, UnrecoverableKeyException, IOException { + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, password); // initialise the keystore + + List pemEncodeableCerts = PEMEncodable.decodeAll(pemEncodedCerts, password); + List certs = pemEncodeableCerts.stream().map(PEMEncodable::toCertificate).filter(Objects::nonNull).collect(Collectors.toList()); + + List pemEncodeableKeys = PEMEncodable.decodeAll(pemEncodedKey, password); + if (pemEncodeableKeys.size() != 1) { + throw new IOException("expected one key but got " + pemEncodeableKeys.size()); + } + + PrivateKey privateKey = pemEncodeableKeys.get(0).toPrivateKey(); + + keyStore.setKeyEntry("keychain", privateKey, password, certs.toArray(new Certificate[] {})); + + return keyStore; + } + + /** + * {@inheritDoc} + */ + @Override + public String toString() { + return "PEMEntryKeyStoreSource{pemCertChain=******,pemKey=******}"; + } + + @Extension + public static class DescriptorImpl extends KeyStoreSourceDescriptor { + + @NonNull + @Override + public String getDisplayName() { + return Messages.CertificateCredentialsImpl_PEMEntryKeyStoreSourceDisplayName(); + } + + @Restricted(NoExternalUse.class) + @POST + public FormValidation doCheckCertChain(@QueryParameter String value) { + String pemCerts = Secret.fromString(value).getPlainText(); + try { + List pemEncodables = PEMEncodable.decodeAll(pemCerts, null); + long count = pemEncodables.stream().map(PEMEncodable::toCertificate).filter(Objects::nonNull).count(); + if (count < 1) { + return FormValidation.error(Messages.CertificateCredentialsImpl_PEMNoCertificates()); + } + // ensure only certs are provided. + if (pemEncodables.size() != count) { + return FormValidation.error(Messages.CertificateCredentialsImpl_PEMNoCertificates()); + } + Certificate cert = pemEncodables.get(0).toCertificate(); + if (cert instanceof X509Certificate) { + X509Certificate x509 = (X509Certificate) cert; + return FormValidation.ok(x509.getSubjectDN().getName()); + } + // no details + return FormValidation.ok(); + } catch (UnrecoverableKeyException | IOException e) { + String message = e.getMessage(); + if (message != null) { + return FormValidation.error(e, Messages.CertificateCredentialsImpl_PEMCertificateParsingError(message)); + } + return FormValidation.error(e, Messages.CertificateCredentialsImpl_PEMCertificateParsingError("unkown reason")); + } + } + + @Restricted(NoExternalUse.class) + @POST + public FormValidation doCheckPrivateKey(@QueryParameter String value, + @RelativePath("..") + @QueryParameter String password) { + String key = Secret.fromString(value).getPlainText(); + try { + List pemEncodables = PEMEncodable.decodeAll(key, toCharArray(Secret.fromString(password))); + long count = pemEncodables.stream().map(PEMEncodable::toPrivateKey).filter(Objects::nonNull).count(); + if (count == 0) { + return FormValidation.error(Messages.CertificateCredentialsImpl_PEMNoKeys()); + } + if (count > 1) { + return FormValidation.error(Messages.CertificateCredentialsImpl_PEMMultipleKeys()); + } + // ensure only keys are provided. + if (pemEncodables.size() != 1) { + return FormValidation.error(Messages.CertificateCredentialsImpl_PEMNonKeys()); + } + PrivateKey pk = pemEncodables.get(0).toPrivateKey(); + String format; + String length; + if (pk instanceof RSAPrivateKey) { + format = "RSA"; + length = ((RSAKey)pk).getModulus().bitLength() + " bit"; + } else if (pk instanceof ECPrivateKey) { + format = "elliptic curve (EC)"; + length = ((ECPrivateKey)pk).getParams().getOrder().bitLength() + " bit"; + } else if (pk instanceof DSAPrivateKey) { + format = "DSA"; + length = ((DSAPrivateKey)pk).getParams().getP().bitLength() + " bit"; + } else if (pk instanceof DHPrivateKey) { + format = "Diffie-Hellman"; + length = ((DHPrivateKey)pk).getParams().getP().bitLength() + " bit"; + } else if (pk != null) { + // spotbugs things pk may be null, but we have already checked + // the size of pemEncodables is one and contains a private key + // so it can not be + format = "unknown format (" + pk.getClass() +")"; + length = "unknown strength"; + } else { // pk == null can not happen + return FormValidation.error("there is a bug in the code, pk is null!"); + } + try { + pk.destroy(); + } catch (@SuppressWarnings("unused") DestroyFailedException ignored) { + // best effort + } + return FormValidation.ok(Messages.CertificateCredentialsImpl_PEMKeyInfo(length, format)); + } catch (UnrecoverableKeyException | IOException e) { + return FormValidation.error(e, Messages.CertificateCredentialsImpl_PEMKeyParseError(e.getLocalizedMessage())); + } } } diff --git a/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMEntryKeyStoreSource/config.jelly b/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMEntryKeyStoreSource/config.jelly new file mode 100644 index 000000000..b823363da --- /dev/null +++ b/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMEntryKeyStoreSource/config.jelly @@ -0,0 +1,34 @@ + + + + + + + + + + + + diff --git a/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMEntryKeyStoreSource/help-certChain.html b/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMEntryKeyStoreSource/help-certChain.html new file mode 100644 index 000000000..f6c88ec2b --- /dev/null +++ b/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMEntryKeyStoreSource/help-certChain.html @@ -0,0 +1,37 @@ + + +
+ A certificate chain containing one or more PEM encoded certificates. + The certificates must be in order such that each one directly certifies the preceding one. + The certificate, for which the private key will be entered below must appear first. +

The entry should look something like the following:

+
+-----BEGIN CERTIFICATE----- 
+Base64 encoded contents 
+-----END CERTIFICATE----- 
+-----BEGIN CERTIFICATE----- 
+Base64 encoded contents  
+-----END CERTIFICATE----
+
\ No newline at end of file diff --git a/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMEntryKeyStoreSource/help-privateKey.html b/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMEntryKeyStoreSource/help-privateKey.html new file mode 100644 index 000000000..49c7226fd --- /dev/null +++ b/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMEntryKeyStoreSource/help-privateKey.html @@ -0,0 +1,32 @@ + + +
+ A single PEM encoded private key that is the key for the primary certificate entered above. +

The entry should look something like the following:

+
+-----BEGIN PRIVATE KEY----- 
+Base64 encoded contents 
+-----END PRIVATE KEY----- 
+
\ No newline at end of file diff --git a/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/UploadedKeyStoreSource/config.jelly b/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/UploadedKeyStoreSource/config.jelly index feac2db35..f0f25d667 100644 --- a/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/UploadedKeyStoreSource/config.jelly +++ b/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/UploadedKeyStoreSource/config.jelly @@ -55,6 +55,11 @@ uploadedCertFileInput.onchange = fileOnChange.bind(uploadedCertFileInput); } function fileOnChange() { + // only trigger validation if the PKCS12 upload is selected + var e = document.getElementById("${fileId}"); + if (e.closest(".form-container").className.indexOf("-hidden") != -1) { + return + } try { // inspired by https://stackoverflow.com/a/754398 var uploadedCertFileInputFile = uploadedCertFileInput.files[0]; var reader = new FileReader(); diff --git a/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/credentials.jelly b/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/credentials.jelly index 5e86264ed..28a479022 100644 --- a/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/credentials.jelly +++ b/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/credentials.jelly @@ -37,7 +37,7 @@ - + diff --git a/src/main/resources/com/cloudbees/plugins/credentials/impl/Messages.properties b/src/main/resources/com/cloudbees/plugins/credentials/impl/Messages.properties index b517bdfb8..608714530 100644 --- a/src/main/resources/com/cloudbees/plugins/credentials/impl/Messages.properties +++ b/src/main/resources/com/cloudbees/plugins/credentials/impl/Messages.properties @@ -28,5 +28,19 @@ CertificateCredentialsImpl.LoadKeyFailed=Could retrieve key "{0}" CertificateCredentialsImpl.LoadKeyFailedQueryEmptyPassword=Could retrieve key "{0}". You may need to provide a password CertificateCredentialsImpl.LoadKeystoreFailed=Could not load keystore CertificateCredentialsImpl.NoCertificateUploaded=No certificate uploaded -CertificateCredentialsImpl.UploadedKeyStoreSourceDisplayName=Upload PKCS#12 certificate - +CertificateCredentialsImpl.UploadedKeyStoreSourceDisplayName=Upload PKCS#12 certificate and key +CertificateCredentialsImpl.PEMEntryKeyStoreSourceDisplayName=PEM encoded certificate and key +CertificateCredentialsImpl.PEMNoCertificates=No certificates where provided +CertificateCredentialsImpl.PEMNoKey=No key was provided +CertificateCredentialsImpl.PEMNoPassword=No password was provided +CertificateCredentialsImpl.ShortPassword=Password is short (< 14 characters) +CertificateCredentialsImpl.ShortPasswordFIPS=Password is too short (< 14 characters) +CertificateCredentialsImpl.NoPassword=Password is empty +CertificateCredentialsImpl.PEMNoCertificate=No Certificates provided +CertificateCredentialsImpl.PEMNonCertificates=PEM contains non certificate entries +CertificateCredentialsImpl.PEMCertificateParsingError=Could not parse certificate chain: {0} +CertificateCredentialsImpl.PEMNoKeys=No Keys Provided +CertificateCredentialsImpl.PEMMultipleKeys=More than 1 key provided +CertificateCredentialsImpl.PEMNonKeys=PEM contains non key entries +CertificateCredentialsImpl.PEMKeyInfo={0} {1} private key +CertificateCredentialsImpl.PEMKeyParseError=Could not parse key: {0} diff --git a/src/test/java/com/cloudbees/plugins/credentials/casc/CredentialsCategoryTest.java b/src/test/java/com/cloudbees/plugins/credentials/casc/CredentialsCategoryTest.java index 05f722992..236c02172 100644 --- a/src/test/java/com/cloudbees/plugins/credentials/casc/CredentialsCategoryTest.java +++ b/src/test/java/com/cloudbees/plugins/credentials/casc/CredentialsCategoryTest.java @@ -17,6 +17,7 @@ import static io.jenkins.plugins.casc.misc.Util.toYamlString; import io.jenkins.plugins.casc.model.CNode; import java.io.ByteArrayOutputStream; + import jenkins.model.GlobalConfiguration; import jenkins.model.GlobalConfigurationCategory; import static org.hamcrest.CoreMatchers.equalTo; @@ -101,12 +102,13 @@ public void exportUsernamePasswordCredentialsImplConfiguration() throws Exceptio @Test public void exportCertificateCredentialsImplConfiguration() throws Exception { + byte[] p12Bytes = CertificateCredentialsImpl.class.getResourceAsStream("test.p12").readAllBytes(); CertificateCredentialsImpl certificateCredentials = new CertificateCredentialsImpl(CredentialsScope.GLOBAL, "credential-certificate", "Credential with certificate", "password", - new CertificateCredentialsImpl.UploadedKeyStoreSource(null, SecretBytes.fromBytes("Testing not real certificate".getBytes()))); + new CertificateCredentialsImpl.UploadedKeyStoreSource(null, SecretBytes.fromBytes(p12Bytes))); SystemCredentialsProvider.getInstance().getCredentials().add(certificateCredentials); ByteArrayOutputStream out = new ByteArrayOutputStream(); diff --git a/src/test/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImplTest.java b/src/test/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImplTest.java index 2e8c02f0e..36782545b 100644 --- a/src/test/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImplTest.java +++ b/src/test/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImplTest.java @@ -26,44 +26,38 @@ import com.cloudbees.hudson.plugins.folder.Folder; import com.cloudbees.hudson.plugins.folder.properties.FolderCredentialsProvider; -import com.cloudbees.plugins.credentials.Credentials; import com.cloudbees.plugins.credentials.CredentialsNameProvider; import com.cloudbees.plugins.credentials.CredentialsProvider; import com.cloudbees.plugins.credentials.CredentialsStore; import com.cloudbees.plugins.credentials.SecretBytes; -import com.cloudbees.plugins.credentials.SystemCredentialsProvider; import com.cloudbees.plugins.credentials.common.CertificateCredentials; import com.cloudbees.plugins.credentials.common.StandardCertificateCredentials; -import com.cloudbees.plugins.credentials.domains.Domain; import org.htmlunit.FormEncodingType; import org.htmlunit.HttpMethod; import org.htmlunit.Page; import org.htmlunit.WebRequest; import org.htmlunit.html.DomNode; import org.htmlunit.html.DomNodeList; +import org.htmlunit.html.HtmlButton; import org.htmlunit.html.HtmlElementUtil; import org.htmlunit.html.HtmlFileInput; import org.htmlunit.html.HtmlForm; import org.htmlunit.html.HtmlOption; import org.htmlunit.html.HtmlPage; import org.htmlunit.html.HtmlRadioButtonInput; -import hudson.FilePath; + import hudson.Util; -import hudson.cli.CLICommandInvoker; -import hudson.cli.UpdateJobCommand; import hudson.model.ItemGroup; -import hudson.model.Job; import hudson.security.ACL; import hudson.util.Secret; -import jenkins.model.Jenkins; import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.jvnet.hudson.test.Issue; import org.jvnet.hudson.test.JenkinsRule; -import org.jvnet.hudson.test.recipes.LocalData; import java.io.File; import java.io.IOException; @@ -71,12 +65,10 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.security.KeyStore; import java.util.Base64; -import java.util.Collections; import java.util.List; -import static hudson.cli.CLICommandInvoker.Matcher.failedWith; -import static hudson.cli.CLICommandInvoker.Matcher.succeeded; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; import static org.junit.Assert.*; @@ -91,10 +83,14 @@ public class CertificateCredentialsImplTest { private File p12; private File p12Invalid; + private String pemCert; + private String pemKey; private static final String VALID_PASSWORD = "password"; private static final String INVALID_PASSWORD = "blabla"; private static final String EXPECTED_DISPLAY_NAME = "EMAILADDRESS=me@myhost.mydomain, CN=pkcs12, O=Fort-Funston, L=SanFrancisco, ST=CA, C=US"; + // BC uses a different format even though the file was converted from the pkcs12 file + private static final String EXPECTED_DISPLAY_NAME_PEM = "C=US,ST=CA,L=SanFrancisco,O=Fort-Funston,CN=pkcs12,E=me@myhost.mydomain"; @Before public void setup() throws IOException { @@ -103,6 +99,9 @@ public void setup() throws IOException { p12Invalid = tmp.newFile("invalid.p12"); FileUtils.copyURLToFile(CertificateCredentialsImplTest.class.getResource("invalid.p12"), p12Invalid); + pemCert = IOUtils.toString(CertificateCredentialsImplTest.class.getResource("certs.pem"), StandardCharsets.UTF_8); + pemKey = IOUtils.toString(CertificateCredentialsImplTest.class.getResource("key.pem"), StandardCharsets.UTF_8); + r.jenkins.setCrumbIssuer(null); } @@ -212,7 +211,8 @@ public void doCheckUploadedKeystore_keyStoreInvalid() throws Exception { @Issue("JENKINS-63761") public void fullSubmitOfUploadedKeystore() throws Exception { String certificateDisplayName = r.jenkins.getDescriptor(CertificateCredentialsImpl.class).getDisplayName(); - + String KeyStoreSourceDisplayName = r.jenkins.getDescriptor(CertificateCredentialsImpl.UploadedKeyStoreSource.class).getDisplayName(); + JenkinsRule.WebClient wc = r.createWebClient(); HtmlPage htmlPage = wc.goTo("credentials/store/system/domain/_/newCredentials"); HtmlForm newCredentialsForm = htmlPage.getFormByName("newCredentials"); @@ -234,9 +234,11 @@ public void fullSubmitOfUploadedKeystore() throws Exception { return false; }); assertTrue("The Certificate option was not found in the credentials type select", optionFound); - - HtmlRadioButtonInput keyStoreRadio = htmlPage.getDocumentElement().querySelector("input[name$=keyStoreSource]"); - HtmlElementUtil.click(keyStoreRadio); + + List inputs = htmlPage.getDocumentElement(). + getByXPath("//input[contains(@name, 'keyStoreSource') and following-sibling::label[contains(.,'"+KeyStoreSourceDisplayName+"')]]"); + assertThat("query should return only a singular input", inputs, hasSize(1)); + HtmlElementUtil.click(inputs.get(0)); HtmlFileInput uploadedCertFileInput = htmlPage.getDocumentElement().querySelector("input[type=file][name=uploadedCertFile]"); uploadedCertFileInput.setFiles(p12); @@ -258,6 +260,66 @@ public void fullSubmitOfUploadedKeystore() throws Exception { assertEquals(EXPECTED_DISPLAY_NAME, displayName); } + @Test + @Issue("JENKINS-73335") + public void fullSubmitOfUploadedPEM() throws Exception { + String certificateDisplayName = r.jenkins.getDescriptor(CertificateCredentialsImpl.class).getDisplayName(); + String KeyStoreSourceDisplayName = r.jenkins.getDescriptor(CertificateCredentialsImpl.PEMEntryKeyStoreSource.class).getDisplayName(); + + JenkinsRule.WebClient wc = r.createWebClient(); + HtmlPage htmlPage = wc.goTo("credentials/store/system/domain/_/newCredentials"); + HtmlForm newCredentialsForm = htmlPage.getFormByName("newCredentials"); + + DomNodeList allOptions = htmlPage.getDocumentElement().querySelectorAll("select.dropdownList option"); + boolean optionFound = allOptions.stream().anyMatch(domNode -> { + if (domNode instanceof HtmlOption) { + HtmlOption option = (HtmlOption) domNode; + if (option.getVisibleText().equals(certificateDisplayName)) { + try { + HtmlElementUtil.click(option); + } catch (IOException e) { + throw new RuntimeException(e); + } + return true; + } + } + + return false; + }); + assertTrue("The Certificate option was not found in the credentials type select", optionFound); + + List inputs = htmlPage.getDocumentElement(). + getByXPath("//input[contains(@name, 'keyStoreSource') and following-sibling::label[contains(.,'"+KeyStoreSourceDisplayName+"')]]"); + assertThat("query should return only a singular input", inputs, hasSize(1)); + HtmlElementUtil.click(inputs.get(0)); + + // enable entry of the secret (HACK just click all the Add buttons) + List buttonsByName = htmlPage.getDocumentElement().getByXPath("//button[contains(.,'Add')]"); + assertThat("I need 2 buttons", buttonsByName, hasSize(2)); + for (HtmlButton b : buttonsByName) { + HtmlElementUtil.click(b); + } + + newCredentialsForm.getTextAreaByName("_.certChain").setTextContent(pemCert); + newCredentialsForm.getTextAreaByName("_.privateKey").setTextContent(pemKey); + + // for all the types of credentials + newCredentialsForm.getInputsByName("_.password").forEach(input -> input.setValue(VALID_PASSWORD)); + + List certificateCredentials = CredentialsProvider.lookupCredentialsInItemGroup(CertificateCredentials.class, (ItemGroup) null, ACL.SYSTEM2); + assertThat(certificateCredentials, hasSize(0)); + + r.submit(newCredentialsForm); + + certificateCredentials = CredentialsProvider.lookupCredentialsInItemGroup(CertificateCredentials.class, (ItemGroup) null, ACL.SYSTEM2); + assertThat(certificateCredentials, hasSize(1)); + + CertificateCredentials certificate = certificateCredentials.get(0); + KeyStore ks = certificate.getKeyStore(); + String displayName = StandardCertificateCredentials.NameProvider.getSubjectDN(certificate.getKeyStore()); + assertEquals(EXPECTED_DISPLAY_NAME_PEM, displayName); + } + private String getValidP12_base64() throws Exception { return Base64.getEncoder().encodeToString(Files.readAllBytes(p12.toPath())); } diff --git a/src/test/resources/com/cloudbees/plugins/credentials/impl/certs.pem b/src/test/resources/com/cloudbees/plugins/credentials/impl/certs.pem new file mode 100644 index 000000000..40c1ad895 --- /dev/null +++ b/src/test/resources/com/cloudbees/plugins/credentials/impl/certs.pem @@ -0,0 +1,39 @@ +-----BEGIN CERTIFICATE----- +MIIDRzCCArCgAwIBAgIBATANBgkqhkiG9w0BAQQFADBmMQswCQYDVQQGEwJLRzEL +MAkGA1UECBMCTkExEDAOBgNVBAcTB0JJU0hLRUsxFTATBgNVBAoTDE9wZW5WUE4t +VEVTVDEhMB8GCSqGSIb3DQEJARYSbWVAbXlob3N0Lm15ZG9tYWluMB4XDTA1MDgw +NDE4MTYyMFoXDTE1MDgwMjE4MTYyMFowfDELMAkGA1UEBhMCVVMxCzAJBgNVBAgT +AkNBMRUwEwYDVQQHEwxTYW5GcmFuY2lzY28xFTATBgNVBAoTDEZvcnQtRnVuc3Rv +bjEPMA0GA1UEAxMGcGtjczEyMSEwHwYJKoZIhvcNAQkBFhJtZUBteWhvc3QubXlk +b21haW4wgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJAoGBAMOT0PRbWiTEJTUjjiwW +yPC7hR2ruxshzWcgWZUuNg5RARnnsQfGpBK+kKp4QsJSunVCo2fmUFkU/UGYVVXK +nHMEcDtX2JqVY/bAPjxptn5k1bnvMFkKFnaAZl5Mi0K0s+D9U0ivpIaw1QXdQbw+ +w3STcv1kpy8rmyerH6KOXL1bAgMBAAGjge4wgeswCQYDVR0TBAIwADAsBglghkgB +hvhCAQ0EHxYdT3BlblNTTCBHZW5lcmF0ZWQgQ2VydGlmaWNhdGUwHQYDVR0OBBYE +FP9dcedV6TFtLIWOWXxIQ5h6JR45MIGQBgNVHSMEgYgwgYWAFImmYOO66j6v/GR/ +TL2M0kiN4MxGoWqkaDBmMQswCQYDVQQGEwJLRzELMAkGA1UECBMCTkExEDAOBgNV +BAcTB0JJU0hLRUsxFTATBgNVBAoTDE9wZW5WUE4tVEVTVDEhMB8GCSqGSIb3DQEJ +ARYSbWVAbXlob3N0Lm15ZG9tYWluggEAMA0GCSqGSIb3DQEBBAUAA4GBABP/5mXw +ttXKG6dqQl5kPisFs/c+0j64xytp5/cdB/zMpEWRWTBXtyL3T5T16xs52kJS0VfT +t+jezYbeu/dCdBL8Moz3RTYb1aY2/xymZ433kWjvgtrOzgGlaW3eKXcQpQEyK2v/ +J4q7+oDCElBRilZCm0mBcQsySKsZjGm8BMjh +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDBjCCAm+gAwIBAgIBADANBgkqhkiG9w0BAQQFADBmMQswCQYDVQQGEwJLRzEL +MAkGA1UECBMCTkExEDAOBgNVBAcTB0JJU0hLRUsxFTATBgNVBAoTDE9wZW5WUE4t +VEVTVDEhMB8GCSqGSIb3DQEJARYSbWVAbXlob3N0Lm15ZG9tYWluMB4XDTA0MTEy +NTE0NDA1NVoXDTE0MTEyMzE0NDA1NVowZjELMAkGA1UEBhMCS0cxCzAJBgNVBAgT +Ak5BMRAwDgYDVQQHEwdCSVNIS0VLMRUwEwYDVQQKEwxPcGVuVlBOLVRFU1QxITAf +BgkqhkiG9w0BCQEWEm1lQG15aG9zdC5teWRvbWFpbjCBnzANBgkqhkiG9w0BAQEF +AAOBjQAwgYkCgYEAqPjWJnesPu6bR/iec4FMz3opVaPdBHxg+ORKNmrnVZPh0t8/ +ZT34KXkYoI9B82scurp8UlZVXG8JdUsz+yai8ti9+g7vcuyKUtcCIjn0HLgmdPu5 +gFX25lB0pXw+XIU031dOfPvtROdG5YZN5yCErgCy7TE7zntLnkEDuRmyU6cCAwEA +AaOBwzCBwDAdBgNVHQ4EFgQUiaZg47rqPq/8ZH9MvYzSSI3gzEYwgZAGA1UdIwSB +iDCBhYAUiaZg47rqPq/8ZH9MvYzSSI3gzEahaqRoMGYxCzAJBgNVBAYTAktHMQsw +CQYDVQQIEwJOQTEQMA4GA1UEBxMHQklTSEtFSzEVMBMGA1UEChMMT3BlblZQTi1U +RVNUMSEwHwYJKoZIhvcNAQkBFhJtZUBteWhvc3QubXlkb21haW6CAQAwDAYDVR0T +BAUwAwEB/zANBgkqhkiG9w0BAQQFAAOBgQBfJoiWYrYdjM0mKPEzUQk0nLYTovBP +I0es/2rfGrin1zbcFY+4dhVBd1E/StebnG+CP8r7QeEIwu7x8gYDdOLLsZn+2vBL +e4jNU1ClI6Q0L7jrzhhunQ5mAaZztVyYwFB15odYcdN2iO0tP7jtEsvrRqxICNy3 +8itzViPTf5W4sA== +-----END CERTIFICATE----- diff --git a/src/test/resources/com/cloudbees/plugins/credentials/impl/key.pem b/src/test/resources/com/cloudbees/plugins/credentials/impl/key.pem new file mode 100644 index 000000000..0a6376f27 --- /dev/null +++ b/src/test/resources/com/cloudbees/plugins/credentials/impl/key.pem @@ -0,0 +1,18 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIC1DBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIgQMvnL0ahAUCAggA +MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECJebW2HlBGc+BIICgGGVZNXyaNYJ +Uzwe7qEkgqBoJLQdQH5oTS6oC2ETwGb4IWf3Q76ag/vaWFYZp7iEwUzatycbKaLj +Uos74QRwmN2W+7jRVLvf0KXLseyR0CNGqj6+O0Ms+abERMSHECslqgGQAdkh3ivT +bDjtAjlP7VjAbXhn21htk4/54fo2o1+w1OIWBuf1g33/AjD+9wzrsESH29yssMOX +5hok885VbvIwyJLdSyKLDCv3xSdT8L9X2gYl4CAzSOwwJuFT1bSA4EjE8R/G+ANT +53sRQq+f/pt/NlNLkKd38Fa7ufk049oaPiIY+c/Kw/zibXrtwXvi57Quofm/qpXC +BBz71XoeksLwkCrn8PCtrZiSheGg01Z7TfumrG1PZdccqr7q8q0XjxzYCo53qnNq +lYhi9hNhmghLeWctdlY3IoN7cIig4z/ALwOMQmjaqYaLU7Maqm8lEqfgnPp9isjp +JtBr705AAU117f3F+MTWtOI6w+T4VABliLfk9JJqQz8SDkVjYOpLpGBnMJYlZTwS +FFfPEKFvOwh7q0Hy5hUovuc8Nd5X2G94h/E1vJumTBmmZepJ2nmDFiEjuejodwcA +wAXtmm7p+dSDXi2+0i9WRB24M7UNdEwxOfVjLNNSqK/GFNK4YHz0efrD/K2qg/+D +DHYgMPuzKtvb8wd0PiNxBY7EauB5Ge/jKTz0UIqqPvt6Hw6FuEiHKPsKXtaBc+II +LQWJyWd6J0d5ol+4FB/Eu5BWoRkTjukWGAkb9IscVC0cF2oHiKcCrboajIlUavnv +d3DpvVJ3xKwTQ0UdYa3jvMargu1LaXVccIlck9B6YHowi7sv9y+y64NHg1K9Bjdj +SpXz7h5osB4= +-----END ENCRYPTED PRIVATE KEY-----