From 462e240f0a99d5821154b09bc1ac3a23b8195670 Mon Sep 17 00:00:00 2001 From: James Nord Date: Mon, 24 Jun 2024 17:50:03 +0100 Subject: [PATCH] [JENKINS-73335] add support for certificates that are not PKCS12 formatted Provides an alternative method for entering Certificate credentials, required when running in FIPS as we can not use PKCS12 --- pom.xml | 7 +- .../impl/CertificateCredentialsImpl.java | 358 ++++++++++++++---- .../PEMUploadedKeyStoreSource/config.jelly | 108 ++++++ .../credentials/impl/Messages.properties | 3 +- 4 files changed, 399 insertions(+), 77 deletions(-) create mode 100644 src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMUploadedKeyStoreSource/config.jelly diff --git a/pom.xml b/pom.xml index f60ed1b70..800de47f9 100644 --- a/pom.xml +++ b/pom.xml @@ -105,7 +105,12 @@ configuration-as-code true - + + org.jenkins-ci.plugins + bouncycastle-api + + 2.30.1.78.1-238.v991b_a_c571a_29 + 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..c63b2490f 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.PluginManager; import hudson.Util; import hudson.model.AbstractDescribableImpl; import hudson.model.Descriptor; @@ -41,18 +42,26 @@ import java.io.ObjectStreamException; import java.io.Serializable; import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.Key; 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.util.ArrayList; 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 jenkins.bouncycastle.api.PEMEncodable; import jenkins.model.Jenkins; import jenkins.security.FIPS140; import net.jcip.annotations.GuardedBy; @@ -143,17 +152,18 @@ public synchronized KeyStore getKeyStore() { 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 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; @@ -220,12 +230,15 @@ 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. + * @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 +249,16 @@ public static abstract class KeyStoreSource extends AbstractDescribableImpl { + 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())); + } + /** * {@inheritDoc} */ @@ -268,67 +327,6 @@ 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 +448,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} */ @@ -479,7 +492,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 +555,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,10 +571,205 @@ 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 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(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 uploaded file containing a set of PEM encoded certificates and a key. + */ + public static class PEMUploadedKeyStoreSource extends KeyStoreSource implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * The uploaded PEM certs and key. + */ + private final SecretBytes pemBytes; + + /** + * Constructor able to receive file directly + * + * @param uploadedCertFile the file containing PEM encoded certs and key. + * @param uploadedKeystore the PEM data, in case the file is not uploaded (e.g. update of the password / description) + */ + @SuppressWarnings("unused") // by stapler + @DataBoundConstructor + public PEMUploadedKeyStoreSource(FileItem uploadedPemFile, @CheckForNull SecretBytes pemBytes) { + if (uploadedPemFile != null) { + byte[] fileBytes = uploadedPemFile.get(); + if (fileBytes.length != 0) { + pemBytes = SecretBytes.fromBytes(fileBytes); + } + } + this.pemBytes = pemBytes; + } + + /** + * Returns the private key file name. + * + * @return the private key file name. + */ + public SecretBytes getPemBytes() { + return pemBytes; + } + + /** + * {@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 { + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + // PEM (rfc7468) only defined the textual encoding + // the data is always encapsulated in base64 + // the pre-emble defined to be labelchar ( %x21-2C / %x2E-7E any printable character; except hyphen-minus) + // As far as text is concerned this is all just ascii, however this is the text representation not what may be stored on disk + // mostly but not always this would only affect us if we where dealing with some esoteric single byte encoding or multibyte encoding + // for most purposes this is just going to be ASCII but lets assume UTF-8 (to match the bouncycastle plugin read methods) + String pem = new String(pemBytes.getPlainData(), StandardCharsets.UTF_8); + + return toKeyStore(pem, password); + } + + protected static KeyStore toKeyStore(String pem, char[] password) throws NoSuchAlgorithmException, CertificateException, KeyStoreException, KeyStoreException, UnrecoverableKeyException, IOException { + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + List pemEncodeables = PEMEncodable.decodeAll(pem, password); + + // add the certs first + int i = 0; + for (PEMEncodable pe : pemEncodeables) { + Certificate cert = pe.toCertificate(); + if (cert != null) { + keyStore.setCertificateEntry("cert-"+ i++, cert); + } + } + // then the private keys so we already have the cert entries + i = 0; + for (PEMEncodable pe : pemEncodeables) { + PrivateKey pk = pe.toPrivateKey(); + if (pk != null) { + keyStore.setKeyEntry("key-" + i++, pk, password, null); + } + } + // XXX if something else (like a public key) was provided we should error... + return keyStore; + } - return validateCertificateKeystore("PKCS12", keystoreBytes, password); + /** + * {@inheritDoc} + */ + @Override + public String toString() { + return "PEMUploadedKeyStoreSource{pemBytes=******}"; + } + + @Extension + public static class DescriptorImpl extends KeyStoreSourceDescriptor { + + public static final String DEFAULT_VALUE = UploadedKeyStoreSource.class.getName() + ".default-value"; + + @NonNull + @Override + public String getDisplayName() { + return Messages.CertificateCredentialsImpl_PEMUploadedKeyStoreSourceDisplayName(); } + /** + * Checks the keystore content. + * + * @param value the keystore content. + * @param password the password. + * @return the {@link FormValidation} results. + */ + @SuppressWarnings("unused") // stapler form validation + @Restricted(NoExternalUse.class) + @RequirePOST + public FormValidation doCheckUploadedPemFile(@QueryParameter String value, + @QueryParameter String uploadedPemFile, + @QueryParameter String password) { + // Priority for the file, to cover the (re-)upload cases + if (StringUtils.isNotEmpty(uploadedPemFile)) { + byte[] uploadedCertFileBytes = Base64.getDecoder().decode(uploadedPemFile.getBytes(StandardCharsets.UTF_8)); + return validateCertificateKeystore(uploadedCertFileBytes, password); + } + + if (StringUtils.isBlank(value)) { + return FormValidation.error(Messages.CertificateCredentialsImpl_NoCertificateUploaded()); + } + if (DEFAULT_VALUE.equals(value)) { + return FormValidation.ok(); + } + + // If no file, we rely on the previous value, stored as SecretBytes in an hidden input + SecretBytes secretBytes = SecretBytes.fromString(value); + byte[] keystoreBytes = secretBytes.getPlainData(); + if (keystoreBytes == null || keystoreBytes.length == 0) { + return FormValidation.error(Messages.CertificateCredentialsImpl_LoadKeystoreFailed()); + } + return validateCertificateKeystore(keystoreBytes, password); + } + + private FormValidation validateCertificateKeystore(byte[] keystoreBytes, String password) { + char[] passwordChars = toCharArray(Secret.fromString(password)); + String pem = new String(keystoreBytes, StandardCharsets.UTF_8); + try { + KeyStore ks = PEMUploadedKeyStoreSource.toKeyStore(pem, passwordChars); + return validateCertificateKeystore(ks, passwordChars); + } catch (KeyStoreException | CertificateException | NoSuchAlgorithmException | UnrecoverableKeyException | IOException e) { + return FormValidation.warning(e, Messages.CertificateCredentialsImpl_LoadKeystoreFailed()); + } finally { + if (passwordChars != null) { + Arrays.fill(passwordChars, ' '); + } + } + } } } diff --git a/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMUploadedKeyStoreSource/config.jelly b/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMUploadedKeyStoreSource/config.jelly new file mode 100644 index 000000000..60a12c903 --- /dev/null +++ b/src/main/resources/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl/PEMUploadedKeyStoreSource/config.jelly @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + 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..3bd7cc6ef 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,6 @@ 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.PEMUploadedKeyStoreSourceDisplayName=Upload PEM encoded certificate and key