diff --git a/pom.xml b/pom.xml index dea5df6c..fb27565d 100644 --- a/pom.xml +++ b/pom.xml @@ -168,6 +168,32 @@ workflow-basic-steps test + + + org.jenkins-ci.plugins + credentials-binding + test + + diff --git a/src/main/java/com/cloudbees/plugins/credentials/CredentialsProvider.java b/src/main/java/com/cloudbees/plugins/credentials/CredentialsProvider.java index 221172e9..d94790b5 100644 --- a/src/main/java/com/cloudbees/plugins/credentials/CredentialsProvider.java +++ b/src/main/java/com/cloudbees/plugins/credentials/CredentialsProvider.java @@ -867,6 +867,7 @@ public static C findCredentialById(@NonNull String id, /** * @deprecated Use {@link #findCredentialById(String, Class, Run, List)} instead. */ + @Deprecated public static C findCredentialById(@NonNull String id, @NonNull Class type, @NonNull Run run, DomainRequirement... domainRequirements) { diff --git a/src/main/java/com/cloudbees/plugins/credentials/SecretBytes.java b/src/main/java/com/cloudbees/plugins/credentials/SecretBytes.java index d687c15e..9f79470a 100644 --- a/src/main/java/com/cloudbees/plugins/credentials/SecretBytes.java +++ b/src/main/java/com/cloudbees/plugins/credentials/SecretBytes.java @@ -35,6 +35,7 @@ import edu.umd.cs.findbugs.annotations.CheckForNull; import edu.umd.cs.findbugs.annotations.NonNull; import hudson.Util; +import hudson.remoting.Channel; import hudson.util.Secret; import java.io.Serializable; import java.security.GeneralSecurityException; @@ -52,7 +53,9 @@ * salt and padding so no two invocations of {@link #getEncryptedData()} will return the same result, but all will * decrypt to the same {@link #getPlainData()}. XStream serialization and Stapler form-binding will assume that * the {@link #toString()} representation is used (i.e. the Base64 encoded secret bytes wrapped with { - * and }. If the string representation fails to decrypt (and is not wrapped + * and }). If the string representation fails to decrypt (and is not wrapped) the {@link #fromString} + * method returns a {@link SecretBytes} representation of an empty array, and {@link #isSecretBytes} method + * returns {@code false}. * * @since 2.1.5 */ @@ -70,7 +73,10 @@ public class SecretBytes implements Serializable { */ private static final long serialVersionUID = 1L; /** - * The key that encrypts the data on disk. + * The key that encrypts the data on disk.
+ * + * NOTE: Unique per JVM run-time, so different value on a + * Jenkins controller and remote agents or across restarts! */ private static final CredentialsConfidentialKey KEY = new CredentialsConfidentialKey(SecretBytes.class, "KEY"); /** @@ -78,7 +84,7 @@ public class SecretBytes implements Serializable { */ private static final Logger LOGGER = Logger.getLogger(SecretBytes.class.getName()); /** - * The unencrypted bytes. + * The encrypted bytes. */ @NonNull private final byte[] value; @@ -138,7 +144,12 @@ public byte[] getPlainData() { Cipher cipher = KEY.decrypt(salt); return cipher.doFinal(encryptedBytes); } catch (GeneralSecurityException e) { - throw new Error(e); + if (Channel.current() == null) { + // Local JVM of the Jenkins controller + throw new Error(e); + } else { + throw new Error("Failed to access secret data by remote agent", e); + } } } 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 2ac56a4c..51bc66b1 100644 --- a/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl.java +++ b/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImpl.java @@ -23,6 +23,7 @@ */ package com.cloudbees.plugins.credentials.impl; +import com.cloudbees.plugins.credentials.CredentialsProvider; import com.cloudbees.plugins.credentials.CredentialsScope; import com.cloudbees.plugins.credentials.SecretBytes; import com.cloudbees.plugins.credentials.common.StandardCertificateCredentials; @@ -35,6 +36,7 @@ import hudson.model.AbstractDescribableImpl; import hudson.model.Descriptor; import hudson.model.Items; +import hudson.remoting.Channel; import hudson.util.FormValidation; import hudson.util.Secret; import java.io.ByteArrayInputStream; @@ -156,6 +158,21 @@ private static char[] toCharArray(@NonNull Secret password) { return password.getPlainText().toCharArray(); } + /** + * When serializing over a {@link Channel} ensure that we send a self-contained version. + * + * @return the object instance to write to the stream. + */ + private Object writeReplace() { + if (/* XStream */ Channel.current() == null + || /* already safe to serialize */ keyStoreSource + .isSnapshotSource() + ) { + return this; + } + return CredentialsProvider.snapshot(this); + } + /** * Returns the {@link KeyStore} containing the certificate. * @@ -369,17 +386,18 @@ public static class UploadedKeyStoreSource extends KeyStoreSource implements Ser /** * The old uploaded keystore. + * Still used for snapshot taking, with contents independent of Jenkins instance and JVM. */ @CheckForNull @Deprecated - private transient Secret uploadedKeystore; + private Secret uploadedKeystore; /** * The uploaded keystore. * * @since 2.1.5 */ @CheckForNull - private final SecretBytes uploadedKeystoreBytes; + private SecretBytes uploadedKeystoreBytes; /** * Our constructor. @@ -409,6 +427,19 @@ public UploadedKeyStoreSource(@CheckForNull SecretBytes uploadedKeystore) { this.uploadedKeystoreBytes = uploadedKeystore; } + /** + * Our constructor for serialization (e.g. to remote agents, whose SecretBytes + * in another JVM use a different static KEY); would re-encode. + * + * @param uploadedKeystore the keystore content. + * @deprecated + */ + @SuppressWarnings("unused") // by stapler + @Deprecated + public UploadedKeyStoreSource(@CheckForNull Secret uploadedKeystore) { + this.uploadedKeystore = uploadedKeystore; + } + /** * Constructor able to receive file directly * @@ -428,6 +459,18 @@ public UploadedKeyStoreSource(FileItem uploadedCertFile, @CheckForNull SecretByt this.uploadedKeystoreBytes = uploadedKeystore; } + /** + * Request that if the less-efficient but more-portable Secret + * is involved (e.g. to cross the remoting gap to another JVM), + * we use the more secure and efficient SecretBytes. + */ + public void useSecretBytes() { + if (this.uploadedKeystore != null && this.uploadedKeystoreBytes == null) { + this.uploadedKeystoreBytes = SecretBytes.fromBytes(DescriptorImpl.toByteArray(this.uploadedKeystore)); + this.uploadedKeystore = null; + } + } + /** * Migrate to the new field. * @@ -444,11 +487,14 @@ private Object readResolve() throws ObjectStreamException { } /** - * Returns the private key file name. + * Returns the private key + certificate file bytes. * - * @return the private key file name. + * @return the private key + certificate file bytes. */ public SecretBytes getUploadedKeystore() { + if (uploadedKeystore != null && uploadedKeystoreBytes == null) { + return SecretBytes.fromBytes(DescriptorImpl.toByteArray(uploadedKeystore)); + } return uploadedKeystoreBytes; } @@ -458,6 +504,9 @@ public SecretBytes getUploadedKeystore() { @NonNull @Override public byte[] getKeyStoreBytes() { + if (uploadedKeystore != null && uploadedKeystoreBytes == null) { + return DescriptorImpl.toByteArray(uploadedKeystore); + } return SecretBytes.getPlainData(uploadedKeystoreBytes); } @@ -474,7 +523,11 @@ public long getKeyStoreLastModified() { */ @Override public boolean isSnapshotSource() { - return true; + //return this.snapshotSecretBytes; + // If context is local, clone SecretBytes directly (only + // usable in same JVM). Otherwise use Secret for transport + // (see {@link CertificateCredentialsSnapshotTaker}. + return (/* XStream */ Channel.current() == null); } @Override @@ -486,7 +539,7 @@ public KeyStore toKeyStore(char[] password) throws NoSuchAlgorithmException, Cer 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 + // legacy behaviour that assumed all KeyStoreSources were in the non FIPS compliant PKCS12 format KeyStore keyStore = KeyStore.getInstance("PKCS12"); keyStore.load(new ByteArrayInputStream(getKeyStoreBytes()), password); return keyStore; diff --git a/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsSnapshotTaker.java b/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsSnapshotTaker.java new file mode 100644 index 00000000..90697d7e --- /dev/null +++ b/src/main/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsSnapshotTaker.java @@ -0,0 +1,97 @@ +/* + * The MIT License + * + * Copyright (c) 2011-2016, CloudBees, Inc., Stephen Connolly. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + + +package com.cloudbees.plugins.credentials.impl; + +import com.cloudbees.plugins.credentials.CredentialsSnapshotTaker; +import com.cloudbees.plugins.credentials.SecretBytes; +import com.cloudbees.plugins.credentials.common.StandardCertificateCredentials; +import hudson.Extension; +import hudson.util.Secret; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.CertificateException; +import java.util.Arrays; + +/** + * The {@link CredentialsSnapshotTaker} for {@link StandardCertificateCredentials}. + * Taking a snapshot of the credential ensures that all the details are captured + * within the credential. + * + * @since 1.14 + * + * Historic note: This code was dropped from {@link CertificateCredentialsImpl} + * codebase along with most of FileOnMasterKeyStoreSource (deprecated and headed + * towards eventual deletion) due to SECURITY-1322, see more details at + * https://www.jenkins.io/security/advisory/2019-05-21/ + * In hind-sight, this snapshot taker was needed to let the + * {@link CertificateCredentialsImpl.UploadedKeyStoreSource} be used + * on remote agents. + */ +@Extension +public class CertificateCredentialsSnapshotTaker extends CredentialsSnapshotTaker { + + /** + * {@inheritDoc} + */ + @Override + public Class type() { + return StandardCertificateCredentials.class; + } + + /** + * {@inheritDoc} + */ + @Override + public StandardCertificateCredentials snapshot(StandardCertificateCredentials credentials) { + if (credentials instanceof CertificateCredentialsImpl) { + final CertificateCredentialsImpl.KeyStoreSource keyStoreSource = ((CertificateCredentialsImpl) credentials).getKeyStoreSource(); + if (keyStoreSource.isSnapshotSource()) { + return credentials; + } + return new CertificateCredentialsImpl(credentials.getScope(), credentials.getId(), + credentials.getDescription(), credentials.getPassword().getEncryptedValue(), + new CertificateCredentialsImpl.UploadedKeyStoreSource(CertificateCredentialsImpl.UploadedKeyStoreSource.DescriptorImpl.toSecret(keyStoreSource.getKeyStoreBytes()))); + } + + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + final char[] password = credentials.getPassword().getPlainText().toCharArray(); + try { + credentials.getKeyStore().store(bos, password); + bos.close(); + } catch (KeyStoreException | IOException | NoSuchAlgorithmException | CertificateException e) { + return credentials; // as-is + } finally { + Arrays.fill(password, (char) 0); + } + + return new CertificateCredentialsImpl(credentials.getScope(), credentials.getId(), + credentials.getDescription(), credentials.getPassword().getEncryptedValue(), + new CertificateCredentialsImpl.UploadedKeyStoreSource(CertificateCredentialsImpl.UploadedKeyStoreSource.DescriptorImpl.toSecret(bos.toByteArray()))); + } +} diff --git a/src/test/java/com/cloudbees/plugins/credentials/CredentialsInPipelineTest.java b/src/test/java/com/cloudbees/plugins/credentials/CredentialsInPipelineTest.java new file mode 100644 index 00000000..127fcf97 --- /dev/null +++ b/src/test/java/com/cloudbees/plugins/credentials/CredentialsInPipelineTest.java @@ -0,0 +1,664 @@ +/* + * The MIT License + * + * Copyright 2022 Jim Klimov. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package com.cloudbees.plugins.credentials; + +import com.cloudbees.plugins.credentials.impl.CertificateCredentialsImpl; +import com.cloudbees.plugins.credentials.impl.CertificateCredentialsImplTest; +import com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl; +import hudson.model.Descriptor; +import hudson.model.Descriptor.FormException; +import hudson.model.Label; +import hudson.model.Node; +import hudson.model.Result; +import hudson.model.Slave; +import hudson.slaves.RetentionStrategy; +import org.apache.commons.io.FileUtils; +import org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition; +import org.jenkinsci.plugins.workflow.job.WorkflowJob; +import org.jenkinsci.plugins.workflow.job.WorkflowRun; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.jvnet.hudson.test.Issue; +import org.jvnet.hudson.test.JenkinsRule; +import org.jvnet.hudson.test.junit.jupiter.WithJenkins; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * The CredentialsInPipelineTest suite prepares pipeline scripts to + * retrieve some previously saved credentials, on the controller, + * on a node provided by it, and on a worker agent in separate JVM. + * This picks known-working test cases and their setup from other + * test classes which address those credential types in more detail. + * Initially tied to JENKINS-70101 research. + */ +@WithJenkins +public class CredentialsInPipelineTest { + /** For developers: set to `true` so that pipeline console logs show + * up in System.out (and/or System.err) of the plugin test run by + *
+     *   mvn test -Dtest="CredentialsInPipelineTest"
+     * 
+ */ + private boolean verbosePipelines = false; + + private JenkinsRule r; + + // Data for build agent setup + /** Build agent label expected by test cases for remote logic execution + * and data transfer */ + private final static String agentLabelString = "cred-test-worker"; + // Can this be reused for many test cases? + private Slave agent = null; + /** Tri-state Unknown/started/not usable */ + private Boolean agentUsable = null; + + // From CertificateCredentialImplTest + @TempDir + private File tmp; + private File p12; + + @BeforeEach + void setup(JenkinsRule r) throws IOException { + this.r = r; + r.jenkins.setCrumbIssuer(null); + } + + private Boolean isAvailableAgent() { + // Can be used to skip optional tests if we know we could not set up an agent + if (agent == null) + return false; + return agentUsable; + } + + private Boolean setupAgent() throws OutOfMemoryError, Exception { + if (isAvailableAgent()) + return true; + + // Note we anticipate this might fail e.g. due to system resources; + // it should not block the whole test suite from running + // (we would just dynamically skip certain test cases) + try { + // Define a "Permanent Agent" + Label agentLabel = Label.get(agentLabelString); + agent = r.createOnlineSlave(agentLabel); + agent.setNodeDescription("Worker in another JVM, remoting used"); + agent.setNumExecutors(1); + agent.setMode(Node.Mode.EXCLUSIVE); + ///agent.setRetentionStrategy(new RetentionStrategy.Always()); + +/* + // Add node envvars + List env = new ArrayList(); + env.add(new Entry("key1","value1")); + env.add(new Entry("key2","value2")); + EnvironmentVariablesNodeProperty envPro = new EnvironmentVariablesNodeProperty(env); + agent.getNodeProperties().add(envPro); +*/ + + String agentLog = null; + agentUsable = false; + for (long i = 0; i < 5; i++) { + Thread.sleep(1000); + agentLog = agent.getComputer().getLog(); + if (i == 2 && (agentLog == null || agentLog.isEmpty())) { + // Give it a little time to autostart, then kick it up if needed: + agent.getComputer().connect(true); // "always" should have started it; avoid duplicate runs + } + if (agentLog != null && agentLog.contains("Agent successfully connected and online")) { + agentUsable = true; + break; + } + } + System.out.println("Spawned build agent " + + "usability: " + agentUsable.toString() + + "; connection log:" + (agentLog == null ? " " : "\n" + agentLog)); + } catch (Descriptor.FormException | NullPointerException e) { + agentUsable = false; + } + + return agentUsable; + } + + private String getLogAsStringPlaintext(WorkflowRun f) throws java.io.IOException { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + f.getLogText().writeLogTo(0, baos); + return baos.toString(); + } + + ///////////////////////////////////////////////////////////////// + // Certificate credentials tests + ///////////////////////////////////////////////////////////////// + + // Partially from CertificateCredentialImplTest setup() + private void prepareUploadedKeystore() throws IOException { + prepareUploadedKeystore("myCert", "password"); + } + + private void prepareUploadedKeystore(String id, String password) throws IOException { + if (p12 == null) { + // Contains a private key + openvpn certs, + // as alias named "1" (according to keytool) + p12 = File.createTempFile("test-keystore-", ".p12", tmp); + FileUtils.copyURLToFile(CertificateCredentialsImplTest.class.getResource("test.p12"), p12); + } + + SecretBytes uploadedKeystore = SecretBytes.fromBytes(Files.readAllBytes(p12.toPath())); + CertificateCredentialsImpl.UploadedKeyStoreSource storeSource = new CertificateCredentialsImpl.UploadedKeyStoreSource(uploadedKeystore); + CertificateCredentialsImpl credentials = new CertificateCredentialsImpl(null, id, null, password, storeSource); + SystemCredentialsProvider.getInstance().getCredentials().add(credentials); + SystemCredentialsProvider.getInstance().save(); + } + + private String cpsScriptCredentialTestImports() { + return "import com.cloudbees.plugins.credentials.CredentialsMatchers;\n" + + "import com.cloudbees.plugins.credentials.CredentialsProvider;\n" + + "import com.cloudbees.plugins.credentials.common.StandardCertificateCredentials;\n" + + "import com.cloudbees.plugins.credentials.common.StandardCredentials;\n" + + "import com.cloudbees.plugins.credentials.common.StandardUsernamePasswordCredentials;\n" + + "import com.cloudbees.plugins.credentials.domains.URIRequirementBuilder;\n" + + "import com.cloudbees.plugins.credentials.impl.CertificateCredentialsImpl;\n" + + "import com.cloudbees.plugins.credentials.impl.CertificateCredentialsImpl.KeyStoreSource;\n" + + "import hudson.security.ACL;\n" + + "import java.security.KeyStore;\n" + + "\n"; + } + + ///////////////////////////////////////////////////////////////// + // Certificate credentials retrievability in (trusted) pipeline + ///////////////////////////////////////////////////////////////// + + private String cpsScriptCertCredentialTestScriptedPipeline(String runnerTag) { + return cpsScriptCertCredentialTestScriptedPipeline("myCert", "password", "1", runnerTag); + } + + private String cpsScriptCertCredentialTestScriptedPipeline(String id, String password, String alias, String runnerTag) { + return "def authentication='" + id + "';\n" + + "def password='" + password + "';\n" + + "def alias='" + alias + "';\n" + + "StandardCredentials credential = CredentialsMatchers.firstOrNull(\n" + + " CredentialsProvider.lookupCredentials(\n" + + " StandardCredentials.class,\n" + + " Jenkins.instance, null, null),\n" + + " CredentialsMatchers.withId(authentication));\n" + + "StandardCredentials credentialSnap = CredentialsProvider.snapshot(credential);\n\n" + + "\n" + + "echo \"CRED ON " + runnerTag + ":\"\n" + + "echo credential.toString()\n" + + "KeyStore keyStore = credential.getKeyStore();\n" + + "KeyStoreSource kss = ((CertificateCredentialsImpl) credential).getKeyStoreSource();\n" + + "echo \"KSS: \" + kss.toString()\n" + + "byte[] kssb = kss.getKeyStoreBytes();\n" + + "echo \"KSS bytes (len): \" + kssb.length\n" + + "String keyValue = keyStore.getKey(alias, password.toCharArray()).getEncoded().encodeBase64().toString()\n" + + "echo \"-----BEGIN PRIVATE KEY-----\"\n" + + "echo keyValue\n" + + "echo \"-----END PRIVATE KEY-----\"\n" + + "\n" + + "echo \"CRED-SNAP ON " + runnerTag + ":\"\n" + + "echo credentialSnap.toString()\n" + + "KeyStore keyStoreSnap = credentialSnap.getKeyStore();\n" + + "KeyStoreSource kssSnap = ((CertificateCredentialsImpl) credentialSnap).getKeyStoreSource();\n" + + "echo \"KSS-SNAP: \" + kssSnap.toString()\n" + + "byte[] kssbSnap = kssSnap.getKeyStoreBytes();\n" + + "echo \"KSS-SNAP bytes (len): \" + kssbSnap.length\n" + + "String keyValueSnap = keyStoreSnap.getKey(alias, password.toCharArray()).getEncoded().encodeBase64().toString()\n" + + "echo \"-----BEGIN PRIVATE KEY-----\"\n" + + "echo keyValueSnap\n" + + "echo \"-----END PRIVATE KEY-----\"\n" + + "\n"; + } + + @Test + @Issue("JENKINS-70101") + void testCertKeyStoreReadableOnController() throws Exception { + // Check that credentials are usable with pipeline script + // running without a node{} + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + cpsScriptCertCredentialTestScriptedPipeline("CONTROLLER BUILT-IN"); + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("KSS-SNAP bytes", run); + } + + @Test + @Issue("JENKINS-70101") + void testCertKeyStoreReadableOnNodeLocal() throws Exception { + // Check that credentials are usable with pipeline script + // running on a node{} (provided by the controller) + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + "node {\n" + + cpsScriptCertCredentialTestScriptedPipeline("CONTROLLER NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("KSS-SNAP bytes", run); + } + + @Test + @Issue("JENKINS-70101") + void testCertKeyStoreReadableOnNodeRemote() throws Exception { + // Check that credentials are usable with pipeline script + // running on a remote node{} with separate JVM (check + // that remoting/snapshot work properly) + assumeTrue(this.setupAgent() == true, "This test needs a separate build agent"); + + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + "node(\"" + agentLabelString + "\") {\n" + + cpsScriptCertCredentialTestScriptedPipeline("REMOTE NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("KSS-SNAP bytes", run); + } + + ///////////////////////////////////////////////////////////////// + // Certificate credentials retrievability by withCredentials() step + ///////////////////////////////////////////////////////////////// + + private String cpsScriptCertCredentialTestGetKeyValue() { + return "@NonCPS\n" + + "def getKeyValue(def keystoreName, def keystoreFormat, def keyPassword, def alias) {\n" + + " def p12file = new FileInputStream(keystoreName)\n" + + " def keystore = KeyStore.getInstance(keystoreFormat)\n" + + " keystore.load(p12file, keyPassword.toCharArray())\n" + + " p12file.close()\n" + + " def key = keystore.getKey(alias, keyPassword.toCharArray())\n" + + " return key.getEncoded().encodeBase64().toString()\n" + + "}\n" + + "\n"; + } + + private String cpsScriptCertCredentialTestWithCredentials(String runnerTag) { + return cpsScriptCertCredentialTestWithCredentials("myCert", "password", "1", runnerTag); + } + + private String cpsScriptCertCredentialTestWithCredentials(String id, String password, String alias, String runnerTag) { + // Note: does not pass a(ny) useful (env?.)myKeyAlias to closure + // https://issues.jenkins.io/browse/JENKINS-59331 + // https://github.com/jenkinsci/credentials-binding-plugin/blob/fcd22059ac48b87d0924ef17d5b351a3b7a89a97/src/main/java/org/jenkinsci/plugins/credentialsbinding/impl/CertificateMultiBinding.java#L80-L81 + return "def authentication='" + id + "';\n" + + "def password='" + password + "';\n" + + "def alias='" + alias + "';\n" + + "echo \"WITH-CREDENTIALS ON " + runnerTag + ":\"\n" + + "withCredentials([certificate(\n" + + " credentialsId: authentication,\n" + + " keystoreVariable: 'keystoreName',\n" + + " passwordVariable: 'keyPassword',\n" + + " aliasVariable: 'myKeyAlias')\n" + + "]) {\n" + + " echo \"Keystore bytes (len): \" + (new File(keystoreName)).length()\n" + + " echo \"Got expected key pass? ${keyPassword == password}\"\n" + + " def keystoreFormat = \"PKCS12\"\n" + + " def keyValue = getKeyValue(keystoreName, keystoreFormat, keyPassword, (env?.myKeyAlias ? env?.myKeyAlias : alias))\n" + + " println \"-----BEGIN PRIVATE KEY-----\"\n" + + " println keyValue\n" + + " println \"-----END PRIVATE KEY-----\"\n" + + "}\n" + + "\n"; + } + + @Test + @Disabled("Work with keystore file requires a node") + @Issue("JENKINS-70101") + void testCertWithCredentialsOnController() throws Exception { + // Check that credentials are usable with pipeline script + // running without a node{} + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + cpsScriptCertCredentialTestGetKeyValue() + + cpsScriptCertCredentialTestWithCredentials("CONTROLLER BUILT-IN"); + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("END PRIVATE KEY", run); + } + + @Test + @Issue("JENKINS-70101") + void testCertWithCredentialsOnNodeLocal() throws Exception { + // Check that credentials are usable with pipeline script + // running on a node{} (provided by the controller) + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + cpsScriptCertCredentialTestGetKeyValue() + + "node {\n" + + cpsScriptCertCredentialTestWithCredentials("CONTROLLER NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("END PRIVATE KEY", run); + } + + @Test + @Issue("JENKINS-70101") + void testCertWithCredentialsOnNodeRemote() throws Exception { + // Check that credentials are usable with pipeline script + // running on a remote node{} with separate JVM (check + // that remoting/snapshot work properly) + assumeTrue(this.setupAgent() == true, "This test needs a separate build agent"); + + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + cpsScriptCertCredentialTestGetKeyValue() + + "node(\"" + agentLabelString + "\") {\n" + + cpsScriptCertCredentialTestWithCredentials("REMOTE NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("END PRIVATE KEY", run); + } + + ///////////////////////////////////////////////////////////////// + // Certificate credentials retrievability by http-request-plugin + ///////////////////////////////////////////////////////////////// +/* + private String cpsScriptCertCredentialTestHttpRequest(String runnerTag) { + return cpsScriptCredentialTestHttpRequest("myCert", runnerTag, true); + } + + private String cpsScriptCredentialTestHttpRequest(String id, String runnerTag, Boolean withLocalCertLookup) { + // Note: we accept any outcome (for the plugin, unresolved host is HTTP-404) + // but it may not crash making use of the credential + // Note: cases withLocalCertLookup also need cpsScriptCredentialTestImports() + return "def authentication='" + id + "';\n" + + "\n" + + "def msg\n" + + (withLocalCertLookup ? ( + "if (true) { // scoping\n" + + " msg = \"Finding credential...\"\n" + + " echo msg;" + (verbosePipelines ? " System.out.println(msg); System.err.println(msg)" : "" ) + ";\n" + + " StandardCredentials credential = CredentialsMatchers.firstOrNull(\n" + + " CredentialsProvider.lookupCredentials(\n" + + " StandardCredentials.class,\n" + + " Jenkins.instance, null, null),\n" + + " CredentialsMatchers.withId(authentication));\n" + + " msg = \"Getting keystore...\"\n" + + " echo msg;" + (verbosePipelines ? " System.out.println(msg); System.err.println(msg)" : "" ) + ";\n" + + " KeyStore keyStore = credential.getKeyStore();\n" + + " msg = \"Getting keystore source...\"\n" + + " echo msg;" + (verbosePipelines ? " System.out.println(msg); System.err.println(msg)" : "" ) + ";\n" + + " KeyStoreSource kss = ((CertificateCredentialsImpl) credential).getKeyStoreSource();\n" + + " msg = \"Getting keystore source bytes...\"\n" + + " echo msg;" + (verbosePipelines ? " System.out.println(msg); System.err.println(msg)" : "" ) + ";\n" + + " byte[] kssb = kss.getKeyStoreBytes();\n" + + "}\n" ) + : "" ) + + "\n" + + "msg = \"Querying HTTPS with cert...\"\n" + + "echo msg;" + (verbosePipelines ? " System.out.println(msg); System.err.println(msg)" : "" ) + ";\n" + + "def response = httpRequest(url: 'https://github.xcom/api/v3',\n" + + " httpMode: 'GET',\n" + + " authentication: authentication,\n" + + " consoleLogResponseBody: true,\n" + + " contentType : 'APPLICATION_FORM',\n" + + " validResponseCodes: '100:599',\n" + + " quiet: false)\n" + + "println('HTTP Request Plugin Status: '+ response.getStatus())\n" + + "println('HTTP Request Plugin Response: '+ response.getContent())\n" + + "\n"; + } + + @Test + @Issue("JENKINS-70101") + void testCertHttpRequestOnController() throws Exception { + // Check that credentials are usable with pipeline script + // running without a node{} + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + cpsScriptCertCredentialTestHttpRequest("CONTROLLER BUILT-IN"); + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("HTTP Request Plugin Response: ", run); + } + + @Test + @Issue("JENKINS-70101") + void testCertHttpRequestOnNodeLocal() throws Exception { + // Check that credentials are usable with pipeline script + // running on a node{} (provided by the controller) + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + "node {\n" + + cpsScriptCertCredentialTestHttpRequest("CONTROLLER NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("HTTP Request Plugin Response: ", run); + } + + @Test + @Issue("JENKINS-70101") + void testCertHttpRequestOnNodeRemote() throws Exception { + // Check that credentials are usable with pipeline script + // running on a remote node{} with separate JVM (check + // that remoting/snapshot work properly) + assumeTrue(this.setupAgent() == true, "This test needs a separate build agent"); + + prepareUploadedKeystore(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptCredentialTestImports() + + "node(\"" + agentLabelString + "\") {\n" + + cpsScriptCertCredentialTestHttpRequest("REMOTE NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("HTTP Request Plugin Response: ", run); + } + + ///////////////////////////////////////////////////////////////// + // User/pass credentials tests + ///////////////////////////////////////////////////////////////// + + // Partially from UsernamePasswordCredentialsImplTest setup() + private void prepareUsernamePassword() throws IOException, FormException { + UsernamePasswordCredentialsImpl credentials = + new UsernamePasswordCredentialsImpl(null, + "abc123", "Bob’s laptop", + "bob", "s3cr3t"); + SystemCredentialsProvider.getInstance().getCredentials().add(credentials); + SystemCredentialsProvider.getInstance().save(); + } + + private String cpsScriptUsernamePasswordCredentialTestHttpRequest(String runnerTag) { + return cpsScriptCredentialTestHttpRequest("abc123", runnerTag, false); + } + + @Test + @Issue("JENKINS-70101") + void testUsernamePasswordHttpRequestOnController() throws Exception { + // Check that credentials are usable with pipeline script + // running without a node{} + prepareUsernamePassword(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = cpsScriptUsernamePasswordCredentialTestHttpRequest("CONTROLLER BUILT-IN"); + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("HTTP Request Plugin Response: ", run); + } + + @Test + @Issue("JENKINS-70101") + void testUsernamePasswordHttpRequestOnNodeLocal() throws Exception { + // Check that credentials are usable with pipeline script + // running on a node{} (provided by the controller) + prepareUsernamePassword(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = + "node {\n" + + cpsScriptUsernamePasswordCredentialTestHttpRequest("CONTROLLER NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("HTTP Request Plugin Response: ", run); + } + + @Test + @Issue("JENKINS-70101") + void testUsernamePasswordHttpRequestOnNodeRemote() throws Exception { + // Check that credentials are usable with pipeline script + // running on a remote node{} with separate JVM (check + // that remoting/snapshot work properly) + assumeTrue(this.setupAgent() == true, "This test needs a separate build agent"); + + prepareUsernamePassword(); + + // Configure the build to use the credential + WorkflowJob proj = r.jenkins.createProject(WorkflowJob.class, "proj"); + String script = + "node(\"" + agentLabelString + "\") {\n" + + cpsScriptUsernamePasswordCredentialTestHttpRequest("REMOTE NODE") + + "}\n"; + proj.setDefinition(new CpsFlowDefinition(script, false)); + + // Execute the build + WorkflowRun run = proj.scheduleBuild2(0).get(); + if (verbosePipelines) System.out.println(getLogAsStringPlaintext(run)); + + // Check expectations + r.assertBuildStatus(Result.SUCCESS, run); + // Got to the end? + r.assertLogContains("HTTP Request Plugin Response: ", run); + } + +*/ +} 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 658fa371..f06b4c47 100644 --- a/src/test/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImplTest.java +++ b/src/test/java/com/cloudbees/plugins/credentials/impl/CertificateCredentialsImplTest.java @@ -26,6 +26,7 @@ import com.cloudbees.plugins.credentials.CredentialsNameProvider; import com.cloudbees.plugins.credentials.CredentialsProvider; +import com.cloudbees.plugins.credentials.CredentialsScope; import com.cloudbees.plugins.credentials.SecretBytes; import com.cloudbees.plugins.credentials.common.CertificateCredentials; import com.cloudbees.plugins.credentials.common.StandardCertificateCredentials; @@ -45,6 +46,7 @@ import org.htmlunit.html.HtmlRadioButtonInput; import hudson.Util; +import hudson.model.Node; import hudson.security.ACL; import hudson.util.Secret; import org.apache.commons.io.FileUtils; @@ -66,6 +68,8 @@ import java.util.Base64; import java.util.List; +import jenkins.security.MasterToSlaveCallable; + import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.hasSize; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -93,9 +97,9 @@ public class CertificateCredentialsImplTest { @BeforeEach void setup(JenkinsRule r) throws IOException { this.r = r; - p12 = File.createTempFile("test.p12", null, tmp); + p12 = File.createTempFile("test-keystore-", ".p12", tmp); FileUtils.copyURLToFile(CertificateCredentialsImplTest.class.getResource("test.p12"), p12); - p12Invalid = File.createTempFile("invalid.p12", null, tmp); + p12Invalid = File.createTempFile("invalid-keystore-", ".p12", tmp); FileUtils.copyURLToFile(CertificateCredentialsImplTest.class.getResource("invalid.p12"), p12Invalid); pemCert = IOUtils.toString(CertificateCredentialsImplTest.class.getResource("certs.pem"), StandardCharsets.UTF_8); @@ -310,6 +314,46 @@ void fullSubmitOfUploadedPEM() throws Exception { assertEquals(EXPECTED_DISPLAY_NAME_PEM, displayName); } + /** Helper for {@link #useCertificateCredentialsImplOnBuiltinAgent} + * and {@link #useCertificateCredentialsImplOnRemoteAgent} test cases */ + private static class ReadCertificateCredentialsOnAgent extends MasterToSlaveCallable { + private final CertificateCredentialsImpl credentials; + + public ReadCertificateCredentialsOnAgent(CertificateCredentialsImpl credentials) { + this.credentials = credentials; + } + + @Override + public String call() throws Throwable { + KeyStore keyStore = credentials.getKeyStore(); + // KeyStore is not Serializable, so we just return the DN. + return StandardCertificateCredentials.NameProvider.getSubjectDN(keyStore); + } + } + + @Test + @Issue("JENKINS-70101") + public void useCertificateCredentialsImplOnBuiltinAgent() throws Throwable { + SecretBytes uploadedKeystore = SecretBytes.fromBytes(Files.readAllBytes(p12.toPath())); + CertificateCredentialsImpl.UploadedKeyStoreSource storeSource = new CertificateCredentialsImpl.UploadedKeyStoreSource(null, uploadedKeystore); + CertificateCredentialsImpl credentials = new CertificateCredentialsImpl(CredentialsScope.GLOBAL, "my-credentials", "description", VALID_PASSWORD, storeSource); + + // There should be no trouble without transfer to another JVM: + assertEquals(EXPECTED_DISPLAY_NAME, r.jenkins.getChannel().call(new ReadCertificateCredentialsOnAgent(credentials))); + } + + @Test + @Issue("JENKINS-70101") + public void useCertificateCredentialsImplOnRemoteAgent() throws Throwable { + SecretBytes uploadedKeystore = SecretBytes.fromBytes(Files.readAllBytes(p12.toPath())); + CertificateCredentialsImpl.UploadedKeyStoreSource storeSource = new CertificateCredentialsImpl.UploadedKeyStoreSource(null, uploadedKeystore); + CertificateCredentialsImpl credentials = new CertificateCredentialsImpl(CredentialsScope.GLOBAL, "my-credentials", "description", VALID_PASSWORD, storeSource); + + // Check for trouble with transfer to another JVM (should be fixed by a solution to JENKINS-70101): + Node node = r.createOnlineSlave(); + assertEquals(EXPECTED_DISPLAY_NAME, node.getChannel().call(new ReadCertificateCredentialsOnAgent(credentials))); + } + private String getValidP12_base64() throws Exception { return Base64.getEncoder().encodeToString(Files.readAllBytes(p12.toPath())); }