items = j.getItems();
for (TopLevelItemDescriptor d : Items.all()) {
int cnt=0;
for (TopLevelItem item : items) {
diff --git a/core/src/main/java/hudson/security/Permission.java b/core/src/main/java/hudson/security/Permission.java
index 249a1725329fcfb0c8279eb304a0d8629bd2866c..4e12a11a0d3b5de3a3be4235fb6b88aa62bcf72a 100644
--- a/core/src/main/java/hudson/security/Permission.java
+++ b/core/src/main/java/hudson/security/Permission.java
@@ -43,6 +43,7 @@ import org.jvnet.localizer.Localizable;
* Each permission is represented by a specific instance of {@link Permission}.
*
* @author Kohsuke Kawaguchi
+ * @see https://wiki.jenkins-ci.org/display/JENKINS/Making+your+plugin+behave+in+secured+Jenkins
*/
public final class Permission {
diff --git a/core/src/main/java/hudson/security/RememberMeServicesProxy.java b/core/src/main/java/hudson/security/RememberMeServicesProxy.java
index 78d128477848bb28aa5e5faf0ca9cf49197434fb..6b4ba001a0b7719ea5d73df1b92a0dd614b0651a 100644
--- a/core/src/main/java/hudson/security/RememberMeServicesProxy.java
+++ b/core/src/main/java/hudson/security/RememberMeServicesProxy.java
@@ -23,6 +23,8 @@
*/
package hudson.security;
+import jenkins.model.Jenkins;
+import jenkins.security.ConfidentialStore;
import org.acegisecurity.ui.rememberme.RememberMeServices;
import org.acegisecurity.Authentication;
@@ -33,9 +35,9 @@ import javax.servlet.http.HttpServletResponse;
* {@link RememberMeServices} proxy.
*
*
- * In Hudson, we need {@link jenkins.model.Jenkins} instance to perform remember-me service,
- * because it relies on {@link jenkins.model.Jenkins#getSecretKey()}. However, security
- * filters can be initialized before Hudson is initialized.
+ * In Jenkins, we need {@link Jenkins} instance to perform remember-me service,
+ * because it relies on {@link ConfidentialStore}. However, security
+ * filters can be initialized before Jenkins is initialized.
* (See #1210 for example.)
*
*
diff --git a/core/src/main/java/hudson/security/SecurityRealm.java b/core/src/main/java/hudson/security/SecurityRealm.java
index 9721fd252e57d4f41a67d453e7625afdfc63f0cd..910f7f390ee43c0fb6c61005aae640e5006fd983 100644
--- a/core/src/main/java/hudson/security/SecurityRealm.java
+++ b/core/src/main/java/hudson/security/SecurityRealm.java
@@ -516,10 +516,21 @@ public abstract class SecurityRealm extends AbstractDescribableImpl implements ModelObject {
public DescriptorImpl() {
- super(Jenkins.getInstance().getSecretKey(), System.getProperty("hudson.security.csrf.requestfield", ".crumb"));
+ // salt just needs to be unique, and it doesn't have to be a secret
+ super(Jenkins.getInstance().getLegacyInstanceId(), System.getProperty("hudson.security.csrf.requestfield", ".crumb"));
load();
}
diff --git a/core/src/main/java/hudson/slaves/SlaveComputer.java b/core/src/main/java/hudson/slaves/SlaveComputer.java
index 89f4bf9111f81d3698882a163cdffea3d46f0f38..df062744286b66509e776779d71f58647c468bfe 100644
--- a/core/src/main/java/hudson/slaves/SlaveComputer.java
+++ b/core/src/main/java/hudson/slaves/SlaveComputer.java
@@ -59,6 +59,7 @@ import java.security.Security;
import hudson.util.io.ReopenableFileOutputStream;
import jenkins.model.Jenkins;
+import jenkins.slaves.JnlpSlaveAgentProtocol;
import org.kohsuke.stapler.StaplerRequest;
import org.kohsuke.stapler.StaplerResponse;
import org.kohsuke.stapler.QueryParameter;
@@ -128,6 +129,10 @@ public class SlaveComputer extends Computer {
return acceptingTasks;
}
+ public String getJnlpMac() {
+ return JnlpSlaveAgentProtocol.SLAVE_SECRET.mac(getName());
+ }
+
/**
* Allows a {@linkplain hudson.slaves.ComputerLauncher} or a {@linkplain hudson.slaves.RetentionStrategy} to
* suspend tasks being accepted by the slave computer.
diff --git a/core/src/main/java/hudson/util/Secret.java b/core/src/main/java/hudson/util/Secret.java
index 7055628f53acef9f1d9f33c1cd96b1fb291f20ff..90144ed6afd81dae2b5dd38ff49583655a048dab 100644
--- a/core/src/main/java/hudson/util/Secret.java
+++ b/core/src/main/java/hudson/util/Secret.java
@@ -31,6 +31,7 @@ import com.thoughtworks.xstream.io.HierarchicalStreamWriter;
import com.trilead.ssh2.crypto.Base64;
import jenkins.model.Jenkins;
import hudson.Util;
+import jenkins.security.CryptoConfidentialKey;
import org.kohsuke.stapler.Stapler;
import javax.crypto.SecretKey;
@@ -44,8 +45,8 @@ import java.security.GeneralSecurityException;
* Glorified {@link String} that uses encryption in the persisted form, to avoid accidental exposure of a secret.
*
*
- * Note that since the cryptography relies on {@link jenkins.model.Jenkins#getSecretKey()}, this is not meant as a protection
- * against code running in the same VM, nor against an attacker who has local file system access.
+ * This is not meant as a protection against code running in the same VM, nor against an attacker
+ * who has local file system access on Jenkins master.
*
*
* {@link Secret}s can correctly read-in plain text password, so this allows the existing
@@ -96,9 +97,13 @@ public final class Secret implements Serializable {
}
/**
- * Turns {@link jenkins.model.Jenkins#getSecretKey()} into an AES key.
+ * Turns {@link Jenkins#getSecretKey()} into an AES key.
+ *
+ * @deprecated
+ * This is no longer the key we use to encrypt new information, but we still need this
+ * to be able to decrypt what's already persisted.
*/
- private static SecretKey getKey() throws UnsupportedEncodingException, GeneralSecurityException {
+ /*package*/ static SecretKey getLegacyKey() throws GeneralSecurityException {
String secret = SECRET;
if(secret==null) return Jenkins.getInstance().getSecretKeyAsAES128();
return Util.toAes128Key(secret);
@@ -111,8 +116,7 @@ public final class Secret implements Serializable {
*/
public String getEncryptedValue() {
try {
- Cipher cipher = getCipher("AES");
- cipher.init(Cipher.ENCRYPT_MODE, getKey());
+ Cipher cipher = KEY.encrypt();
// add the magic suffix which works like a check sum.
return new String(Base64.encode(cipher.doFinal((value+MAGIC).getBytes("UTF-8"))));
} catch (GeneralSecurityException e) {
@@ -129,12 +133,14 @@ public final class Secret implements Serializable {
public static Secret decrypt(String data) {
if(data==null) return null;
try {
+ byte[] in = Base64.decode(data.toCharArray());
+ Secret s = tryDecrypt(KEY.decrypt(), in);
+ if (s!=null) return s;
+
+ // try our historical key for backward compatibility
Cipher cipher = getCipher("AES");
- cipher.init(Cipher.DECRYPT_MODE, getKey());
- String plainText = new String(cipher.doFinal(Base64.decode(data.toCharArray())), "UTF-8");
- if(plainText.endsWith(MAGIC))
- return new Secret(plainText.substring(0,plainText.length()-MAGIC.length()));
- return null;
+ cipher.init(Cipher.DECRYPT_MODE, getLegacyKey());
+ return tryDecrypt(cipher, in);
} catch (GeneralSecurityException e) {
return null;
} catch (UnsupportedEncodingException e) {
@@ -144,6 +150,17 @@ public final class Secret implements Serializable {
}
}
+ /*package*/ static Secret tryDecrypt(Cipher cipher, byte[] in) throws UnsupportedEncodingException {
+ try {
+ String plainText = new String(cipher.doFinal(in), "UTF-8");
+ if(plainText.endsWith(MAGIC))
+ return new Secret(plainText.substring(0,plainText.length()-MAGIC.length()));
+ return null;
+ } catch (GeneralSecurityException e) {
+ return null; // if the key doesn't match with the bytes, it can result in BadPaddingException
+ }
+ }
+
/**
* Workaround for JENKINS-6459 / http://java.net/jira/browse/GLASSFISH-11862
* This method uses specific provider selected via hudson.util.Secret.provider system property
@@ -207,10 +224,15 @@ public final class Secret implements Serializable {
private static final String PROVIDER = System.getProperty(Secret.class.getName()+".provider");
/**
- * For testing only. Override the secret key so that we can test this class without {@link jenkins.model.Jenkins}.
+ * For testing only. Override the secret key so that we can test this class without {@link Jenkins}.
*/
/*package*/ static String SECRET = null;
+ /**
+ * The key that encrypts the data on disk.
+ */
+ private static final CryptoConfidentialKey KEY = new CryptoConfidentialKey(Secret.class.getName());
+
private static final long serialVersionUID = 1L;
static {
diff --git a/core/src/main/java/hudson/util/SecretRewriter.java b/core/src/main/java/hudson/util/SecretRewriter.java
new file mode 100644
index 0000000000000000000000000000000000000000..7e24dfda9d7b09038a72ff269658c34d25ad0dfa
--- /dev/null
+++ b/core/src/main/java/hudson/util/SecretRewriter.java
@@ -0,0 +1,223 @@
+package hudson.util;
+
+import com.trilead.ssh2.crypto.Base64;
+import hudson.model.TaskListener;
+import org.apache.commons.io.FileUtils;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import java.io.BufferedReader;
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.security.GeneralSecurityException;
+import java.security.InvalidKeyException;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * Rewrites XML files by looking for Secrets that are stored with the old key and replaces them
+ * by the new encrypted values.
+ *
+ * @author Kohsuke Kawaguchi
+ */
+public class SecretRewriter {
+ private final Cipher cipher;
+ private final SecretKey key;
+
+ /**
+ * How many files have been scanned?
+ */
+ private int count;
+
+ /**
+ * If non-null the original file before rewrite gets in here.
+ */
+ private final File backupDirectory;
+
+ /**
+ * Canonical paths of the directories we are recursing to protect
+ * against symlink induced cycles.
+ */
+ private Set callstack = new HashSet();
+
+ public SecretRewriter(File backupDirectory) throws GeneralSecurityException {
+ cipher = Secret.getCipher("AES");
+ key = Secret.getLegacyKey();
+ this.backupDirectory = backupDirectory;
+ }
+
+ private String tryRewrite(String s) throws IOException, InvalidKeyException {
+ if (s.length()<24)
+ return s; // Encrypting "" in Secret produces 24-letter characters, so this must be the minimum length
+ if (!isBase64(s))
+ return s; // decode throws IOException if the input is not base64, and this is also a very quick way to filter
+
+ byte[] in;
+ try {
+ in = Base64.decode(s.toCharArray());
+ } catch (IOException e) {
+ return s; // not a valid base64
+ }
+ cipher.init(Cipher.DECRYPT_MODE, key);
+ Secret sec = Secret.tryDecrypt(cipher, in);
+ if(sec!=null) // matched
+ return sec.getEncryptedValue(); // replace by the new encrypted value
+ else // not encrypted with the legacy key. leave it unmodified
+ return s;
+ }
+
+ /**
+ * @param backup
+ * if non-null, the original file will be copied here before rewriting.
+ * if the rewrite doesn't happen, no copying.
+ */
+ public boolean rewrite(File f, File backup) throws InvalidKeyException, IOException {
+ FileInputStream fin = new FileInputStream(f);
+ try {
+ BufferedReader r = new BufferedReader(new InputStreamReader(fin, "UTF-8"));
+ AtomicFileWriter w = new AtomicFileWriter(f, "UTF-8");
+ try {
+ PrintWriter out = new PrintWriter(new BufferedWriter(w));
+
+ boolean modified = false; // did we actually change anything?
+ try {
+ String line;
+ StringBuilder buf = new StringBuilder();
+
+ while ((line=r.readLine())!=null) {
+ int copied=0;
+ buf.setLength(0);
+ while (true) {
+ int sidx = line.indexOf('>',copied);
+ if (sidx<0) break;
+ int eidx = line.indexOf('<',sidx);
+ if (eidx<0) break;
+
+ String elementText = line.substring(sidx+1,eidx);
+ String replacement = tryRewrite(elementText);
+ if (!replacement.equals(elementText))
+ modified = true;
+
+ buf.append(line.substring(copied,sidx+1));
+ buf.append(replacement);
+ copied = eidx;
+ }
+ buf.append(line.substring(copied));
+ out.println(buf.toString());
+ }
+ } finally {
+ out.close();
+ }
+
+ if (modified) {
+ if (backup!=null) {
+ backup.getParentFile().mkdirs();
+ FileUtils.copyFile(f,backup);
+ }
+ w.commit();
+ }
+ return modified;
+ } finally {
+ w.abort();
+ }
+ } finally {
+ fin.close();
+ }
+ }
+
+
+ /**
+ * Recursively scans and rewrites a directory.
+ *
+ * This method shouldn't abort just because one file fails to rewrite.
+ *
+ * @return
+ * Number of files that were actually rewritten.
+ */
+ // synchronized to prevent accidental concurrent use. this instance is not thread safe
+ public synchronized int rewriteRecursive(File dir, TaskListener listener) throws InvalidKeyException {
+ return rewriteRecursive(dir,"",listener);
+ }
+ private int rewriteRecursive(File dir, String relative, TaskListener listener) throws InvalidKeyException {
+ String canonical;
+ try {
+ canonical = dir.getCanonicalPath();
+ } catch (IOException e) {
+ canonical = dir.getAbsolutePath(); //
+ }
+ if (!callstack.add(canonical)) {
+ listener.getLogger().println("Cycle detected: "+dir);
+ return 0;
+ }
+
+ try {
+ File[] children = dir.listFiles();
+ if (children==null) return 0;
+
+ int rewritten=0;
+ for (File child : children) {
+ String cn = child.getName();
+ if (cn.endsWith(".xml")) {
+ if ((count++)%100==0)
+ listener.getLogger().println("Scanning "+child);
+ try {
+ File backup = null;
+ if (backupDirectory!=null) backup = new File(backupDirectory,relative+'/'+ cn);
+ if (rewrite(child,backup)) {
+ if (backup!=null)
+ listener.getLogger().println("Copied "+child+" to "+backup+" as a backup");
+ listener.getLogger().println("Rewritten "+child);
+ rewritten++;
+ }
+ } catch (IOException e) {
+ e.printStackTrace(listener.error("Failed to rewrite "+child));
+ }
+ }
+ if (child.isDirectory()) {
+ if (!isIgnoredDir(child))
+ rewritten += rewriteRecursive(child,
+ relative.length()==0 ? cn : relative+'/'+ cn,
+ listener);
+ }
+ }
+ return rewritten;
+ } finally {
+ callstack.remove(canonical);
+ }
+ }
+
+ /**
+ * Decides if this directory is worth visiting or not.
+ */
+ protected boolean isIgnoredDir(File dir) {
+ // ignoring the workspace and the artifacts directories. Both of them
+ // are potentially large and they do not store any secrets.
+ String n = dir.getName();
+ return n.equals("workspace") || n.equals("artifacts")
+ || n.equals("plugins") // no mutable data here
+ || n.equals("jenkins.security.RekeySecretAdminMonitor") // we don't want to rewrite backups
+ || n.equals(".") || n.equals("..");
+ }
+
+ private static boolean isBase64(char ch) {
+ return 0<=ch && ch<128 && IS_BASE64[ch];
+ }
+
+ private static boolean isBase64(String s) {
+ for (int i=0; i
* This value is useful for implementing some of the security features.
+ *
+ * @deprecated
+ * Due to the past security advisory, this value should not be used any more to protect sensitive information.
+ * See {@link ConfidentialStore} and {@link ConfidentialKey} for how to store secrets.
*/
public String getSecretKey() {
return secretKey;
@@ -1003,11 +1014,29 @@ public class Jenkins extends AbstractCIBase implements ModifiableTopLevelItemGro
/**
* Gets {@linkplain #getSecretKey() the secret key} as a key for AES-128.
* @since 1.308
+ * @deprecated
+ * See {@link #getSecretKey()}.
*/
public SecretKey getSecretKeyAsAES128() {
return Util.toAes128Key(secretKey);
}
+ /**
+ * Returns the unique identifier of this Jenkins that has been historically used to identify
+ * this Jenkins to the outside world.
+ *
+ *
+ * This form of identifier is weak in that it can be impersonated by others. See
+ * https://wiki.jenkins-ci.org/display/JENKINS/Instance+Identity for more modern form of instance ID
+ * that can be challenged and verified.
+ *
+ * @since 1.498
+ */
+ @SuppressWarnings("deprecation")
+ public String getLegacyInstanceId() {
+ return Util.getDigestOf(getSecretKey());
+ }
+
/**
* Gets the SCM descriptor by name. Primarily used for making them web-visible.
*/
@@ -1485,7 +1514,7 @@ public class Jenkins extends AbstractCIBase implements ModifiableTopLevelItemGro
}
/**
- * Returns true if the current running Hudson is upgraded from a version earlier than the specified version.
+ * Returns true if the current running Jenkins is upgraded from a version earlier than the specified version.
*
*
* This method continues to return true until the system configuration is saved, at which point
diff --git a/core/src/main/java/jenkins/security/ApiTokenProperty.java b/core/src/main/java/jenkins/security/ApiTokenProperty.java
index 94c10f1173d060a207b01c60532b48cdcb3a9625..caa9ed8853c6d97eb38f7da5d3f2dee8555e4603 100644
--- a/core/src/main/java/jenkins/security/ApiTokenProperty.java
+++ b/core/src/main/java/jenkins/security/ApiTokenProperty.java
@@ -62,12 +62,18 @@ public class ApiTokenProperty extends UserProperty {
* We don't let the external code set the API token,
* but for the initial value of the token we need to compute the seed by ourselves.
*/
- private ApiTokenProperty(String seed) {
+ /*package*/ ApiTokenProperty(String seed) {
apiToken = Secret.fromString(seed);
}
public String getApiToken() {
- return Util.getDigestOf(apiToken.getPlainText());
+ String p = apiToken.getPlainText();
+ if (p.equals(Util.getDigestOf(Jenkins.getInstance().getSecretKey()+":"+user.getId()))) {
+ // if the current token is the initial value created by pre SECURITY-49 Jenkins, we can't use that.
+ // force using the newer value
+ apiToken = Secret.fromString(p=API_KEY_SEED.mac(user.getId()));
+ }
+ return Util.getDigestOf(p);
}
public boolean matchesPassword(String password) {
@@ -103,10 +109,10 @@ public class ApiTokenProperty extends UserProperty {
* because there's no guarantee that the property is saved.
*
* But we also need to make sure that an attacker won't be able to guess
- * the initial API token value. So we take the seed by hasing the instance secret key + user ID.
+ * the initial API token value. So we take the seed by hashing the secret + user ID.
*/
public ApiTokenProperty newInstance(User user) {
- return new ApiTokenProperty(Util.getDigestOf(Jenkins.getInstance().getSecretKey() + ":" + user.getId()));
+ return new ApiTokenProperty(API_KEY_SEED.mac(user.getId()));
}
public HttpResponse doChangeToken(@AncestorInPath User u, StaplerResponse rsp) throws IOException {
@@ -123,4 +129,9 @@ public class ApiTokenProperty extends UserProperty {
}
private static final SecureRandom RANDOM = new SecureRandom();
+
+ /**
+ * We don't want an API key that's too long, so cut the length to 16 (which produces 32-letter MAC code in hexdump)
+ */
+ private static final HMACConfidentialKey API_KEY_SEED = new HMACConfidentialKey(ApiTokenProperty.class,"seed",16);
}
diff --git a/core/src/main/java/jenkins/security/ConfidentialKey.java b/core/src/main/java/jenkins/security/ConfidentialKey.java
new file mode 100644
index 0000000000000000000000000000000000000000..a0167f478d680e5fe1b8931170cb8ca044384fea
--- /dev/null
+++ b/core/src/main/java/jenkins/security/ConfidentialKey.java
@@ -0,0 +1,57 @@
+package jenkins.security;
+
+import hudson.scm.SCM;
+import hudson.tasks.Builder;
+import hudson.util.Secret;
+import jenkins.slaves.JnlpSlaveAgentProtocol;
+
+import javax.annotation.CheckForNull;
+import java.io.IOException;
+
+/**
+ * Confidential information that gets stored as a singleton in Jenkins, mostly some random token value.
+ *
+ *
+ * The actual value is persisted via {@link ConfidentialStore}, but each use case that requires
+ * a secret like this should use a separate {@link ConfidentialKey} instance so that one compromised
+ * {@link ConfidentialKey} (say through incorrect usage and failure to protect it) shouldn't compromise
+ * all the others.
+ *
+ *
+ * {@link ConfidentialKey} is ultimately a sequence of bytes,
+ * but for convenience, a family of subclasses are provided to represent the secret in different formats.
+ * See {@link HexStringConfidentialKey} and {@link HMACConfidentialKey} for example. In addition to the programming
+ * ease, these use case specific subtypes make it harder for vulnerability to creep in by making it harder
+ * for the secret to leak.
+ *
+ *
+ * The {@link ConfidentialKey} subtypes are expected to be used as a singleton, like {@link JnlpSlaveAgentProtocol#SLAVE_SECRET}.
+ * For code that relies on XStream for persistence (such as {@link Builder}s, {@link SCM}s, and other fragment objects
+ * around builds and jobs), {@link Secret} provides more convenient way of storing secrets.
+ *
+ * @author Kohsuke Kawaguchi
+ * @see Secret
+ * @since 1.498
+ */
+public abstract class ConfidentialKey {
+ /**
+ * Name of the key. This is used as the file name.
+ */
+ private final String id;
+
+ protected ConfidentialKey(String id) {
+ this.id = id;
+ }
+
+ protected @CheckForNull byte[] load() throws IOException {
+ return ConfidentialStore.get().load(this);
+ }
+
+ protected void store(byte[] payload) throws IOException {
+ ConfidentialStore.get().store(this,payload);
+ }
+
+ public String getId() {
+ return id;
+ }
+}
diff --git a/core/src/main/java/jenkins/security/ConfidentialStore.java b/core/src/main/java/jenkins/security/ConfidentialStore.java
new file mode 100644
index 0000000000000000000000000000000000000000..de82437653ae24c99bbd2c2814599e2f3e4c4a80
--- /dev/null
+++ b/core/src/main/java/jenkins/security/ConfidentialStore.java
@@ -0,0 +1,97 @@
+package jenkins.security;
+
+import hudson.Extension;
+import hudson.Lookup;
+import hudson.init.InitMilestone;
+import hudson.util.Secret;
+import hudson.util.Service;
+import jenkins.model.Jenkins;
+import org.kohsuke.MetaInfServices;
+
+import javax.annotation.CheckForNull;
+import javax.annotation.Nonnull;
+import java.io.IOException;
+import java.security.SecureRandom;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * The actual storage for the data held by {@link ConfidentialKey}s, and the holder
+ * of the master secret.
+ *
+ *
+ * This class is only relevant for the implementers of {@link ConfidentialKey}s.
+ * Most plugin code should interact with {@link ConfidentialKey}s.
+ *
+ *
+ * OEM distributions of Jenkins can provide a custom {@link ConfidentialStore} implementation
+ * by writing a subclass, mark it with {@link MetaInfServices} annotation, package it as a Jenkins module,
+ * and bundling it with the war file. This doesn't use {@link Extension} because some plugins
+ * have been found to use {@link Secret} before we get to {@link InitMilestone#PLUGINS_PREPARED}, and
+ * therefore {@link Extension}s aren't loaded yet. (Similarly, it's conceivable that some future
+ * core code might need this early on during the boot sequence.)
+ *
+ * @author Kohsuke Kawaguchi
+ * @since 1.498
+ */
+public abstract class ConfidentialStore {
+ /**
+ * Persists the payload of {@link ConfidentialKey} to a persisted storage (such as disk.)
+ * The expectation is that the persisted form is secure.
+ */
+ protected abstract void store(ConfidentialKey key, byte[] payload) throws IOException;
+
+ /**
+ * Reverse operation of {@link #store(ConfidentialKey, byte[])}
+ *
+ * @return
+ * null the data has not been previously persisted, or if the data was tampered.
+ */
+ protected abstract @CheckForNull byte[] load(ConfidentialKey key) throws IOException;
+
+ /**
+ * Works like {@link SecureRandom#nextBytes(byte[])}.
+ *
+ * This enables implementations to consult other entropy sources, if it's available.
+ */
+ public abstract byte[] randomBytes(int size);
+
+ /**
+ * Retrieves the currently active singleton instance of {@link ConfidentialStore}.
+ */
+ public static @Nonnull ConfidentialStore get() {
+ if (TEST!=null) return TEST.get();
+
+ Lookup lookup = Jenkins.getInstance().lookup;
+ ConfidentialStore cs = lookup.get(ConfidentialStore.class);
+ if (cs==null) {
+ try {
+ List r = (List) Service.loadInstances(ConfidentialStore.class.getClassLoader(), ConfidentialStore.class);
+ if (!r.isEmpty())
+ cs = r.get(0);
+ } catch (IOException e) {
+ LOGGER.log(Level.WARNING, "Failed to list up ConfidentialStore implementations",e);
+ // fall through
+ }
+
+ if (cs==null)
+ try {
+ cs = new DefaultConfidentialStore();
+ } catch (Exception e) {
+ // if it's still null, bail out
+ throw new Error(e);
+ }
+
+ cs = lookup.setIfNull(ConfidentialStore.class,cs);
+ }
+ return cs;
+ }
+
+ /**
+ * Testing only. Used for testing {@link ConfidentialKey} without {@link Jenkins}
+ */
+ /*package*/ static ThreadLocal TEST = null;
+
+ private static final Logger LOGGER = Logger.getLogger(ConfidentialStore.class.getName());
+}
diff --git a/core/src/main/java/jenkins/security/CryptoConfidentialKey.java b/core/src/main/java/jenkins/security/CryptoConfidentialKey.java
new file mode 100644
index 0000000000000000000000000000000000000000..dd1dad9e37b96fc9018181b62702aa3217f64e55
--- /dev/null
+++ b/core/src/main/java/jenkins/security/CryptoConfidentialKey.java
@@ -0,0 +1,76 @@
+package jenkins.security;
+
+import hudson.util.Secret;
+
+import javax.crypto.Cipher;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+
+/**
+ * {@link ConfidentialKey} that stores a {@link SecretKey} for shared-secret cryptography (AES).
+ *
+ * @author Kohsuke Kawaguchi
+ * @since 1.498
+ */
+public class CryptoConfidentialKey extends ConfidentialKey {
+ private volatile SecretKey secret;
+ public CryptoConfidentialKey(String id) {
+ super(id);
+ }
+
+ public CryptoConfidentialKey(Class owner, String shortName) {
+ this(owner.getName()+'.'+shortName);
+ }
+
+ private SecretKey getKey() {
+ try {
+ if (secret==null) {
+ synchronized (this) {
+ if (secret==null) {
+ byte[] payload = load();
+ if (payload==null) {
+ payload = ConfidentialStore.get().randomBytes(256);
+ store(payload);
+ }
+ // Due to the stupid US export restriction JDK only ships 128bit version.
+ secret = new SecretKeySpec(payload,0,128/8, ALGORITHM);
+ }
+ }
+ }
+ return secret;
+ } catch (IOException e) {
+ throw new Error("Failed to load the key: "+getId(),e);
+ }
+ }
+
+ /**
+ * Returns a {@link Cipher} object for encrypting with this key.
+ */
+ public Cipher encrypt() {
+ try {
+ Cipher cipher = Secret.getCipher(ALGORITHM);
+ cipher.init(Cipher.ENCRYPT_MODE, getKey());
+ return cipher;
+ } catch (GeneralSecurityException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ /**
+ * Returns a {@link Cipher} object for decrypting with this key.
+ */
+ public Cipher decrypt() {
+ try {
+ Cipher cipher = Secret.getCipher(ALGORITHM);
+ cipher.init(Cipher.DECRYPT_MODE, getKey());
+ return cipher;
+ } catch (GeneralSecurityException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+
+ private static final String ALGORITHM = "AES";
+}
diff --git a/core/src/main/java/jenkins/security/DefaultConfidentialStore.java b/core/src/main/java/jenkins/security/DefaultConfidentialStore.java
new file mode 100644
index 0000000000000000000000000000000000000000..476f437f9f0d5dee40be717ec498acd67df3221b
--- /dev/null
+++ b/core/src/main/java/jenkins/security/DefaultConfidentialStore.java
@@ -0,0 +1,144 @@
+package jenkins.security;
+
+import hudson.FilePath;
+import hudson.Util;
+import hudson.util.IOException2;
+import hudson.util.IOUtils;
+import hudson.util.Secret;
+import hudson.util.TextFile;
+import jenkins.model.Jenkins;
+
+import javax.crypto.Cipher;
+import javax.crypto.CipherInputStream;
+import javax.crypto.CipherOutputStream;
+import javax.crypto.SecretKey;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+
+/**
+ * Default portable implementation of {@link ConfidentialStore} that uses
+ * a directory inside $JENKINS_HOME.
+ *
+ * The master key is also stored in this same directory.
+ *
+ * @author Kohsuke Kawaguchi
+ */
+// @MetaInfServices --- not annotated because this is the fallback implementation
+public class DefaultConfidentialStore extends ConfidentialStore {
+ private final SecureRandom sr = new SecureRandom();
+
+ /**
+ * Directory that stores individual keys.
+ */
+ private final File rootDir;
+
+ /**
+ * The master key.
+ *
+ * The sole purpose of the master key is to encrypt individual keys on the disk.
+ * Because leaking this master key compromises all the individual keys, we must not let
+ * this master key used for any other purpose, hence the protected access.
+ */
+ private final SecretKey masterKey;
+
+ public DefaultConfidentialStore() throws IOException, InterruptedException {
+ this(new File(Jenkins.getInstance().getRootDir(),"secrets"));
+ }
+
+ public DefaultConfidentialStore(File rootDir) throws IOException, InterruptedException {
+ this.rootDir = rootDir;
+ if (rootDir.mkdirs()) {
+ // protect this directory. but don't change the permission of the existing directory
+ // in case the administrator changed this.
+ new FilePath(rootDir).chmod(0700);
+ }
+
+ TextFile masterSecret = new TextFile(new File(rootDir,"master.key"));
+ if (!masterSecret.exists()) {
+ // we are only going to use small number of bits (since export control limits AES key length)
+ // but let's generate a long enough key anyway
+ masterSecret.write(Util.toHexString(randomBytes(128)));
+ }
+ this.masterKey = Util.toAes128Key(masterSecret.readTrim());
+ }
+
+ /**
+ * Persists the payload of {@link ConfidentialKey} to the disk.
+ */
+ @Override
+ protected void store(ConfidentialKey key, byte[] payload) throws IOException {
+ CipherOutputStream cos=null;
+ FileOutputStream fos=null;
+ try {
+ Cipher sym = Secret.getCipher("AES");
+ sym.init(Cipher.ENCRYPT_MODE, masterKey);
+ cos = new CipherOutputStream(fos=new FileOutputStream(getFileFor(key)), sym);
+ cos.write(payload);
+ cos.write(MAGIC);
+ } catch (GeneralSecurityException e) {
+ throw new IOException2("Failed to persist the key: "+key.getId(),e);
+ } finally {
+ IOUtils.closeQuietly(cos);
+ IOUtils.closeQuietly(fos);
+ }
+ }
+
+ /**
+ * Reverse operation of {@link #store(ConfidentialKey, byte[])}
+ *
+ * @return
+ * null the data has not been previously persisted.
+ */
+ @Override
+ protected byte[] load(ConfidentialKey key) throws IOException {
+ CipherInputStream cis=null;
+ FileInputStream fis=null;
+ try {
+ File f = getFileFor(key);
+ if (!f.exists()) return null;
+
+ Cipher sym = Secret.getCipher("AES");
+ sym.init(Cipher.DECRYPT_MODE, masterKey);
+ cis = new CipherInputStream(fis=new FileInputStream(f), sym);
+ byte[] bytes = IOUtils.toByteArray(cis);
+ return verifyMagic(bytes);
+ } catch (GeneralSecurityException e) {
+ throw new IOException2("Failed to persist the key: "+key.getId(),e);
+ } finally {
+ IOUtils.closeQuietly(cis);
+ IOUtils.closeQuietly(fis);
+ }
+ }
+
+ /**
+ * Verifies that the given byte[] has the MAGIC trailer, to verify the integrity of the decryption process.
+ */
+ private byte[] verifyMagic(byte[] payload) {
+ int payloadLen = payload.length-MAGIC.length;
+ if (payloadLen<0) return null; // obviously broken
+
+ for (int i=0; ihash(msg|secret)).
+ *
+ *
+ * This provides more secure version of it by using HMAC.
+ * See http://rdist.root.org/2009/10/29/stop-using-unsafe-keyed-hashes-use-hmac/ for background.
+ * This implementation also never leaks the secret value to outside, so it makes it impossible
+ * for the careless caller to misuse the key (thus protecting ourselves from our own stupidity!)
+ *
+ * @author Kohsuke Kawaguchi
+ * @since 1.498
+ */
+public class HMACConfidentialKey extends ConfidentialKey {
+ private volatile SecretKey key;
+ private final int length;
+
+ /**
+ * @param length
+ * Byte length of the HMAC code.
+ * By default we use HMAC-SHA256, which produces 256bit (=32bytes) HMAC,
+ * but if different use cases requires a shorter HMAC, specify the desired length here.
+ * Note that when using {@link #mac(String)}, string encoding causes the length to double.
+ * So if you want to get 16-letter HMAC, you specify 8 here.
+ */
+ public HMACConfidentialKey(String id, int length) {
+ super(id);
+ this.length = length;
+ }
+
+ /**
+ * Calls into {@link #HMACConfidentialKey(String, int)} with the longest possible HMAC length.
+ */
+ public HMACConfidentialKey(String id) {
+ this(id,Integer.MAX_VALUE);
+ }
+
+ /**
+ * Calls into {@link #HMACConfidentialKey(String, int)} by combining the class name and the shortName
+ * as the ID.
+ */
+ public HMACConfidentialKey(Class owner, String shortName, int length) {
+ this(owner.getName()+'.'+shortName,length);
+ }
+
+ public HMACConfidentialKey(Class owner, String shortName) {
+ this(owner,shortName,Integer.MAX_VALUE);
+ }
+
+
+ /**
+ * Computes the message authentication code for the specified byte sequence.
+ */
+ public byte[] mac(byte[] message) {
+ return chop(createMac().doFinal(message));
+ }
+
+ /**
+ * Convenience method for verifying the MAC code.
+ */
+ public boolean checkMac(byte[] message, byte[] mac) {
+ return Arrays.equals(mac(message),mac);
+ }
+
+ /**
+ * Computes the message authentication code and return it as a string.
+ * While redundant, often convenient.
+ */
+ public String mac(String message) {
+ try {
+ return Util.toHexString(mac(message.getBytes("UTF-8")));
+ } catch (UnsupportedEncodingException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ /**
+ * Verifies MAC constructed from {@link #mac(String)}
+ */
+ public boolean checkMac(String message, String mac) {
+ return mac(message).equals(mac);
+ }
+
+ private byte[] chop(byte[] mac) {
+ if (mac.length<=length) return mac; // already too short
+
+ byte[] b = new byte[length];
+ System.arraycopy(mac,0,b,0,b.length);
+ return b;
+ }
+
+ /**
+ * Creates a new {@link Mac} object.
+ */
+ public Mac createMac() {
+ try {
+ Mac mac = Mac.getInstance(ALGORITHM);
+ mac.init(getKey());
+ return mac;
+ } catch (GeneralSecurityException e) {
+ // Javadoc says HmacSHA256 must be supported by every Java implementation.
+ throw new Error(ALGORITHM+" not supported?",e);
+ }
+ }
+
+ private SecretKey getKey() {
+ if (key==null) {
+ synchronized (this) {
+ if (key==null) {
+ try {
+ byte[] encoded = load();
+ if (encoded==null) {
+ KeyGenerator kg = KeyGenerator.getInstance(ALGORITHM);
+ SecretKey key = kg.generateKey();
+ store(encoded=key.getEncoded());
+ }
+ key = new SecretKeySpec(encoded,ALGORITHM);
+ } catch (IOException e) {
+ throw new Error("Failed to load the key: "+getId(),e);
+ } catch (NoSuchAlgorithmException e) {
+ throw new Error("Failed to load the key: "+getId(),e);
+ }
+ }
+ }
+ }
+ return key;
+ }
+
+ private static final String ALGORITHM = "HmacSHA256";
+}
diff --git a/core/src/main/java/jenkins/security/HexStringConfidentialKey.java b/core/src/main/java/jenkins/security/HexStringConfidentialKey.java
new file mode 100644
index 0000000000000000000000000000000000000000..75fb259bd8ad0b478bf3cb10280f7cd37cc13b2c
--- /dev/null
+++ b/core/src/main/java/jenkins/security/HexStringConfidentialKey.java
@@ -0,0 +1,65 @@
+package jenkins.security;
+
+import hudson.Util;
+
+import java.io.IOException;
+
+/**
+ * {@link ConfidentialKey} that is the random hexadecimal string of length N.
+ *
+ *
+ * This is typically used as a unique ID, as a hex dump is suitable for printing, copy-pasting,
+ * as well as use as an identifier.
+ *
+ * @author Kohsuke Kawaguchi
+ * @since 1.498
+ */
+public class HexStringConfidentialKey extends ConfidentialKey {
+ private final int length;
+
+ private volatile String secret;
+
+ /**
+ * @param length
+ * Length of the hexadecimal string.
+ */
+ public HexStringConfidentialKey(String id, int length) {
+ super(id);
+ if (length%2!=0)
+ throw new IllegalArgumentException("length must be even: "+length);
+ this.length = length;
+ }
+
+ public HexStringConfidentialKey(Class owner, String shortName, int length) {
+ this(owner.getName()+'.'+shortName,length);
+ }
+
+ /**
+ * Returns the persisted hex string value.
+ *
+ * If the value isn't persisted, a new random value is created.
+ *
+ * @throws Error
+ * If the secret fails to load. Not throwing a checked exception is for the convenience
+ * of the caller.
+ */
+ public String get() {
+ try {
+ if (secret==null) {
+ synchronized (this) {
+ if (secret==null) {
+ byte[] payload = load();
+ if (payload==null) {
+ payload = ConfidentialStore.get().randomBytes(length/2);
+ store(payload);
+ }
+ secret = Util.toHexString(payload).substring(0,length);
+ }
+ }
+ }
+ return secret;
+ } catch (IOException e) {
+ throw new Error("Failed to load the key: "+getId(),e);
+ }
+ }
+}
diff --git a/core/src/main/java/jenkins/security/RekeySecretAdminMonitor.java b/core/src/main/java/jenkins/security/RekeySecretAdminMonitor.java
new file mode 100644
index 0000000000000000000000000000000000000000..a5ce72c8241c36eee06282b7a0d0b35d3bdaccf4
--- /dev/null
+++ b/core/src/main/java/jenkins/security/RekeySecretAdminMonitor.java
@@ -0,0 +1,198 @@
+package jenkins.security;
+
+import hudson.Extension;
+import hudson.console.AnnotatedLargeText;
+import hudson.init.InitMilestone;
+import hudson.init.Initializer;
+import hudson.model.AdministrativeMonitor;
+import hudson.util.HttpResponses;
+import hudson.util.SecretRewriter;
+import hudson.util.StreamTaskListener;
+import hudson.util.VersionNumber;
+import jenkins.model.Jenkins;
+import jenkins.util.io.FileBoolean;
+import org.kohsuke.stapler.HttpResponse;
+import org.kohsuke.stapler.StaplerProxy;
+import org.kohsuke.stapler.StaplerRequest;
+import org.kohsuke.stapler.interceptor.RequirePOST;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.charset.Charset;
+import java.security.GeneralSecurityException;
+import java.util.Date;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Warns the administrator to run {@link SecretRewriter}
+ *
+ * @author Kohsuke Kawaguchi
+ */
+@Extension
+public class RekeySecretAdminMonitor extends AdministrativeMonitor implements StaplerProxy {
+
+ /**
+ * Whether we detected a need to run the rewrite program.
+ * Once we set it to true, we'll never turn it off.
+ *
+ * If the admin decides to dismiss this warning, we use {@link #isEnabled()} for that.
+ *
+ * In this way we can correctly differentiate all the different states.
+ */
+ private final FileBoolean needed = state("needed");
+
+ /**
+ * If the scanning process has run to the completion, we set to this true.
+ */
+ private final FileBoolean done = state("done");
+
+ /**
+ * If the rewrite process is scheduled upon the next boot.
+ */
+ private final FileBoolean scanOnBoot = state("scanOnBoot");
+
+ /**
+ * Set to non-null once the rewriting activities starts running.
+ */
+ private volatile RekeyThread rekeyThread;
+
+
+ public RekeySecretAdminMonitor() throws IOException {
+ // if JENKINS_HOME existed <1.497, we need to offer rewrite
+ // this computation needs to be done and the value be captured,
+ // since $JENKINS_HOME/config.xml can be saved later before the user has
+ // actually rewritten XML files.
+ Jenkins j = Jenkins.getInstance();
+ if (j.isUpgradedFromBefore(new VersionNumber("1.496.*"))
+ && new FileBoolean(new File(j.getRootDir(),"secret.key.not-so-secret")).isOff())
+ needed.on();
+ }
+
+ /**
+ * Requires ADMINISTER permission for any operation in here.
+ */
+ public Object getTarget() {
+ Jenkins.getInstance().checkPermission(Jenkins.ADMINISTER);
+ return this;
+ }
+
+ @Override
+ public boolean isActivated() {
+ return needed.isOn();
+ }
+
+ /**
+ * Indicates that the re-keying has run to the completion.
+ */
+ public boolean isDone() {
+ return done.isOn();
+ }
+
+ public void setNeeded() {
+ needed.on();
+ }
+
+ public boolean isScanOnBoot() {
+ return scanOnBoot.isOn();
+ }
+
+ @RequirePOST
+ public HttpResponse doScan(StaplerRequest req) throws IOException, GeneralSecurityException {
+ if(req.hasParameter("background")) {
+ synchronized (this) {
+ if (!isRewriterActive()) {
+ rekeyThread = new RekeyThread();
+ rekeyThread.start();
+ }
+ }
+ } else
+ if(req.hasParameter("schedule")) {
+ scanOnBoot.on();
+ } else
+ if(req.hasParameter("dismiss")) {
+ disable(true);
+ } else
+ throw HttpResponses.error(400,"Invalid request submission");
+
+ return HttpResponses.redirectViaContextPath("/manage");
+ }
+
+ /**
+ * Is there an active rewriting process going on?
+ */
+ public boolean isRewriterActive() {
+ return rekeyThread !=null && rekeyThread.isAlive();
+ }
+
+ /**
+ * Used to URL-bind {@link AnnotatedLargeText}.
+ */
+ public AnnotatedLargeText getLogText() {
+ return new AnnotatedLargeText(getLogFile(), Charset.defaultCharset(),
+ !isRewriterActive(),this);
+ }
+
+ private static FileBoolean state(String name) {
+ return new FileBoolean(new File(getBaseDir(),name));
+ }
+
+ @Initializer(fatal=false,after=InitMilestone.PLUGINS_STARTED,before=InitMilestone.EXTENSIONS_AUGMENTED)
+ // as early as possible, but this needs to be late enough that the ConfidentialStore is available
+ public static void scanOnReboot() throws InterruptedException, IOException, GeneralSecurityException {
+ FileBoolean flag = new RekeySecretAdminMonitor().scanOnBoot;
+ if (flag.isOn()) {
+ flag.off();
+ RekeyThread t = new RekeyThread();
+ t.start();
+ t.join();
+ // block the boot until the rewrite process is complete
+ // don't let the failure in RekeyThread block Jenkins boot.
+ }
+ }
+
+ /**
+ * Rewrite log file.
+ */
+ public static File getLogFile() {
+ return new File(getBaseDir(),"rekey.log");
+ }
+
+ private static File getBaseDir() {
+ return new File(Jenkins.getInstance().getRootDir(),RekeySecretAdminMonitor.class.getName());
+ }
+
+ private static class RekeyThread extends Thread {
+ private final SecretRewriter rewriter;
+
+ RekeyThread() throws GeneralSecurityException {
+ super("Rekey secret thread");
+ rewriter = new SecretRewriter(new File(getBaseDir(),"backups"));
+ }
+
+ @Override
+ public void run() {
+ try {
+ LOGGER.info("Initiating a re-keying of secrets. See "+getLogFile());
+ StreamTaskListener listener = new StreamTaskListener(getLogFile());
+ try {
+ PrintStream log = listener.getLogger();
+ log.println("Started re-keying " + new Date());
+ int count = rewriter.rewriteRecursive(Jenkins.getInstance().getRootDir(), listener);
+ log.printf("Completed re-keying %d files on %s\n",count,new Date());
+ new RekeySecretAdminMonitor().done.on();
+ LOGGER.info("Secret re-keying completed");
+ } catch (Exception e) {
+ LOGGER.log(Level.SEVERE, "Fatal failure in re-keying secrets",e);
+ e.printStackTrace(listener.error("Fatal failure in rewriting secrets"));
+ }
+ } catch (IOException e) {
+ LOGGER.log(Level.SEVERE, "Catastrophic failure to rewrite secrets",e);
+ }
+ }
+ }
+
+ private static final Logger LOGGER = Logger.getLogger(RekeySecretAdminMonitor.class.getName());
+
+}
diff --git a/core/src/main/java/jenkins/slaves/JnlpSlaveAgentProtocol.java b/core/src/main/java/jenkins/slaves/JnlpSlaveAgentProtocol.java
index 2050ce1ecd2bc778128202cdb2c2bb576b518502..664f5e3822facfd83db3adeb5f65367ab76d6344 100644
--- a/core/src/main/java/jenkins/slaves/JnlpSlaveAgentProtocol.java
+++ b/core/src/main/java/jenkins/slaves/JnlpSlaveAgentProtocol.java
@@ -8,6 +8,7 @@ import hudson.remoting.Engine;
import hudson.slaves.SlaveComputer;
import jenkins.AgentProtocol;
import jenkins.model.Jenkins;
+import jenkins.security.HMACConfidentialKey;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
@@ -31,10 +32,10 @@ import java.util.logging.Logger;
* unauthorized remote slaves.
*
*
- * The approach here is to have {@link Jenkins#getSecretKey() a secret key} on the master.
- * This key is sent to the slave inside the .jnlp file
+ * We do this by computing HMAC of the slave name.
+ * This code is sent to the slave inside the .jnlp file
* (this file itself is protected by HTTP form-based authentication that
- * we use everywhere else in Hudson), and the slave sends this
+ * we use everywhere else in Jenkins), and the slave sends this
* token back when it connects to the master.
* Unauthorized slaves can't access the protected .jnlp file,
* so it can't impersonate a valid slave.
@@ -84,12 +85,15 @@ public class JnlpSlaveAgentProtocol extends AgentProtocol {
}
protected void run() throws IOException, InterruptedException {
- if(!getSecretKey().equals(in.readUTF())) {
+ final String secret = in.readUTF();
+ final String nodeName = in.readUTF();
+
+ if(!SLAVE_SECRET.mac(nodeName).equals(secret)) {
error(out, "Unauthorized access");
return;
}
- final String nodeName = in.readUTF();
+
SlaveComputer computer = (SlaveComputer) Jenkins.getInstance().getComputer(nodeName);
if(computer==null) {
error(out, "No such slave: "+nodeName);
@@ -138,10 +142,6 @@ public class JnlpSlaveAgentProtocol extends AgentProtocol {
}
}
- protected String getSecretKey() {
- return Jenkins.getInstance().getSecretKey();
- }
-
protected void error(PrintWriter out, String msg) throws IOException {
out.println(msg);
LOGGER.log(Level.WARNING,Thread.currentThread().getName()+" is aborted: "+msg);
@@ -150,4 +150,9 @@ public class JnlpSlaveAgentProtocol extends AgentProtocol {
}
private static final Logger LOGGER = Logger.getLogger(JnlpSlaveAgentProtocol.class.getName());
+
+ /**
+ * This secret value is used as a seed for slaves.
+ */
+ public static final HMACConfidentialKey SLAVE_SECRET = new HMACConfidentialKey(JnlpSlaveAgentProtocol.class,"secret");
}
diff --git a/core/src/main/java/jenkins/slaves/JnlpSlaveAgentProtocol2.java b/core/src/main/java/jenkins/slaves/JnlpSlaveAgentProtocol2.java
index 413486e38df17104b3dd48fc1693e4add8df0115..86da2de8ae2275d48dd8c49f9f11b5feff3d7960 100644
--- a/core/src/main/java/jenkins/slaves/JnlpSlaveAgentProtocol2.java
+++ b/core/src/main/java/jenkins/slaves/JnlpSlaveAgentProtocol2.java
@@ -57,12 +57,13 @@ public class JnlpSlaveAgentProtocol2 extends JnlpSlaveAgentProtocol {
Properties request = new Properties();
request.load(new ByteArrayInputStream(in.readUTF().getBytes("UTF-8")));
- if(!getSecretKey().equals(request.getProperty("Secret-Key"))) {
+ final String nodeName = request.getProperty("Node-Name");
+
+ if(!SLAVE_SECRET.mac(nodeName).equals(request.getProperty("Secret-Key"))) {
error(out, "Unauthorized access");
return;
}
- final String nodeName = request.getProperty("Node-Name");
SlaveComputer computer = (SlaveComputer) Jenkins.getInstance().getComputer(nodeName);
if(computer==null) {
error(out, "No such slave: "+nodeName);
diff --git a/core/src/main/java/jenkins/util/io/FileBoolean.java b/core/src/main/java/jenkins/util/io/FileBoolean.java
new file mode 100644
index 0000000000000000000000000000000000000000..3800ae287f7bf478f92170744bad43b79e26fc1e
--- /dev/null
+++ b/core/src/main/java/jenkins/util/io/FileBoolean.java
@@ -0,0 +1,61 @@
+package jenkins.util.io;
+
+import jenkins.model.Jenkins;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Uses a presence/absence of a file as a persisted boolean storage.
+ *
+ *
+ * This is convenient when you need to store just a few bits of infrequently accessed information
+ * as you can forget the explicit persistence of it. This class masks I/O problem, so if the persistence
+ * fails, you'll get no error report.
+ *
+ * @author Kohsuke Kawaguchi
+ * @since 1.498
+ */
+public class FileBoolean {
+ private final File file;
+
+ public FileBoolean(File file) {
+ this.file = file;
+ }
+
+ public FileBoolean(Class owner, String name) {
+ this(new File(Jenkins.getInstance().getRootDir(),owner.getName().replace('$','.')+'/'+name));
+ }
+
+ /**
+ * Gets the current state. True if the file exists, false if it doesn't.
+ */
+ public boolean get() {
+ return file.exists();
+ }
+
+ public boolean isOn() { return get(); }
+ public boolean isOff() { return !get(); }
+
+ public void set(boolean b) {
+ if (b) on(); else off();
+ }
+
+ public void on() {
+ try {
+ file.getParentFile().mkdirs();
+ new FileOutputStream(file).close();
+ } catch (IOException e) {
+ LOGGER.log(Level.WARNING, "Failed to touch "+file);
+ }
+ }
+
+ public void off() {
+ file.delete();
+ }
+
+ private static final Logger LOGGER = Logger.getLogger(FileBoolean.class.getName());
+}
diff --git a/core/src/main/java/jenkins/util/xstream/XStreamDOM.java b/core/src/main/java/jenkins/util/xstream/XStreamDOM.java
index 552ccafc4d1d818ededa00e4541c14bc9f8665e0..a4258ab5495b207f71f5f0f9a638edeab35daa25 100644
--- a/core/src/main/java/jenkins/util/xstream/XStreamDOM.java
+++ b/core/src/main/java/jenkins/util/xstream/XStreamDOM.java
@@ -48,6 +48,7 @@ import java.io.StringReader;
import java.io.StringWriter;
import java.io.Writer;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
@@ -239,6 +240,13 @@ public class XStreamDOM {
return new ConverterImpl().unmarshalElement(in, null);
}
+ public Map getAttributeMap() {
+ Map r = new HashMap();
+ for (int i=0; i
-
+
+ ${it.checkPermission(connect)}
+
@@ -61,7 +63,7 @@ THE SOFTWARE.
- ${app.secretKey}
+ ${it.jnlpMac}
${it.node.nodeName}
-tunnel
@@ -87,5 +89,4 @@ THE SOFTWARE.
-
diff --git a/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/log.groovy b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/log.groovy
new file mode 100644
index 0000000000000000000000000000000000000000..ee6eb6e681300b36f65681e925a48c8f4c3bbfa5
--- /dev/null
+++ b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/log.groovy
@@ -0,0 +1,45 @@
+/*
+The MIT License
+
+Copyright (c) 2013, CloudBees, Inc.
+
+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 jenkins.security.RekeySecretAdminMonitor;
+
+def l = namespace(lib.LayoutTagLib)
+def t = namespace(lib.JenkinsTagLib)
+
+l.layout {
+ l.main_panel() {
+ h1 _("Re-keying log")
+
+ if (my.isRewriterActive()) {
+ pre(id: "out")
+ div(id: "spinner") {
+ img(src: "${imagesURL}/spinner.gif", alt: "")
+ }
+ t.progressiveText(spinner: "spinner", href: "logText/progressiveHtml", idref: "out")
+ } else {
+ pre {
+ my.logText.writeHtmlTo(0, output.asWriter())
+ }
+ }
+ }
+}
diff --git a/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.groovy b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.groovy
new file mode 100644
index 0000000000000000000000000000000000000000..ec3eae3993a2402f6e12d0582e096e70c8589bd3
--- /dev/null
+++ b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.groovy
@@ -0,0 +1,59 @@
+/*
+The MIT License
+
+Copyright (c) 2013, CloudBees, Inc.
+
+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 jenkins.security.RekeySecretAdminMonitor;
+
+def f = namespace(lib.FormTagLib)
+
+if (!my.isDone()) {
+ div(class:"error") {
+ raw _("pleaseRekeyAsap",app.rootDir,my.url)
+ }
+}
+
+if (my.isRewriterActive()) {
+ div(class:"info") {
+ raw _("rekeyInProgress",my.url)
+ }
+} else if (my.logFile.exists()) {
+ if (my.isDone()) {
+ div(class:"info") {
+ raw _("rekeySuccessful",my.url)
+ }
+ } else {
+ div(class:"warning") {
+ raw _("rekeyHadProblems",my.url)
+ }
+ }
+}
+
+form(method:"POST",action:"${my.url}/scan",style:"text-align:center; margin-top:0.5em;",name:"rekey") {
+ f.submit(name:"background",value:_("Re-key in background now"))
+ if (my.isScanOnBoot()) {
+ input(type:"button",class:"yui-button",disabled:"true",
+ value:_("Re-keying currently scheduled during the next startup"))
+ } else {
+ f.submit(name:"schedule", value:_("Schedule a re-key during the next startup"))
+ }
+ f.submit(name:"dismiss", value:_("Dismiss this message"))
+}
diff --git a/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.properties b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.properties
new file mode 100644
index 0000000000000000000000000000000000000000..2daf3f7e8fb04d121c47f3b5a1ce6b57e7b64292
--- /dev/null
+++ b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.properties
@@ -0,0 +1,17 @@
+pleaseRekeyAsap=\
+ Because of a security vulnerability discovered earlier, we need to \
+ change the encryption key used to protect secrets in your configuration files on the disk. \
+ This process scans a large portion of your $JENKINS_HOME ({0}), \
+ find encrypted data, re-key them, which will take some time. \
+ See this document for more implications about different ways of doing this \
+ (or not doing this.) This operation can be safely run in background, but cautious users \
+ are recommended to take backups.
+
+rekeyInProgress=Re-keying is in progress. You can check the log.
+
+rekeySuccessful=\
+ Secrets in your $JENKINS_HOME has been re-keyed successfully. \
+ Please check the log, confirm the success, and then dismiss or re-run.
+
+rekeyHadProblems=\
+ Re-keying has completed, but there were problems. Please check the log.
\ No newline at end of file
diff --git a/core/src/test/java/hudson/util/MockSecretRule.java b/core/src/test/java/hudson/util/MockSecretRule.java
new file mode 100644
index 0000000000000000000000000000000000000000..f3c11ea381de23b09741d1ab0be099615926c22b
--- /dev/null
+++ b/core/src/test/java/hudson/util/MockSecretRule.java
@@ -0,0 +1,33 @@
+package hudson.util;
+
+import hudson.Util;
+import org.junit.rules.ExternalResource;
+
+import java.security.SecureRandom;
+
+/**
+ * JUnit rule that cleans that sets a temporary {@link Secret#SECRET} value.
+ *
+ * @author Kohsuke Kawaguchi
+ */
+public class MockSecretRule extends ExternalResource {
+
+ private String value;
+
+ @Override
+ protected void before() throws Throwable {
+ byte[] random = new byte[32];
+ sr.nextBytes(random);
+ value = Util.toHexString(random);
+ Secret.SECRET = value;
+ }
+
+ @Override
+ protected void after() {
+ if (!Secret.SECRET.equals(value))
+ throw new IllegalStateException("Someone tinkered with Secret.SECRET");
+ Secret.SECRET = null;
+ }
+
+ private static final SecureRandom sr = new SecureRandom();
+}
diff --git a/core/src/test/java/hudson/util/SecretRewriterTest.groovy b/core/src/test/java/hudson/util/SecretRewriterTest.groovy
new file mode 100644
index 0000000000000000000000000000000000000000..13522c22ad80f4aa9c02fd2eae366ead85c3e20b
--- /dev/null
+++ b/core/src/test/java/hudson/util/SecretRewriterTest.groovy
@@ -0,0 +1,114 @@
+package hudson.util
+
+import com.trilead.ssh2.crypto.Base64
+import hudson.FilePath
+import jenkins.security.ConfidentialStoreRule
+import org.junit.Rule
+import org.junit.Test
+
+import javax.crypto.Cipher
+
+import static hudson.Util.createTempDir
+
+/**
+ *
+ *
+ * @author Kohsuke Kawaguchi
+ */
+class SecretRewriterTest {
+ @Rule
+ public MockSecretRule mockSecretRule = new MockSecretRule()
+
+ @Rule
+ public ConfidentialStoreRule confidentialStoreRule = new ConfidentialStoreRule();
+
+ @Test
+ void singleFileRewrite() {
+ def o = encryptOld('foobar') // old
+ def n = encryptNew('foobar') // new
+ roundtrip "${o}",
+ "${n}"
+
+ roundtrip "${o}${o}",
+ "${n}${n}"
+
+ roundtrip "${n}",
+ "${n}"
+
+ roundtrip " thisIsLegalBase64AndLongEnoughThatItCouldLookLikeSecret ",
+ " thisIsLegalBase64AndLongEnoughThatItCouldLookLikeSecret "
+
+ // to be rewritten, it needs to be between a tag
+ roundtrip "$o", "$o"
+ roundtrip "$o", "$o"
+
+ //
+ roundtrip "\n$o\n", "\n$n\n"
+ }
+
+ void roundtrip(String before, String after) {
+ def sr = new SecretRewriter(null);
+ def f = File.createTempFile("test","xml");
+ try {
+ f.text = before
+ sr.rewrite(f,null)
+ assert after.trim()==f.text.trim()
+ } finally {
+ f.delete()
+ }
+ }
+
+ String encryptOld(str) {
+ def cipher = Secret.getCipher("AES");
+ cipher.init(Cipher.ENCRYPT_MODE, Secret.legacyKey);
+ return new String(Base64.encode(cipher.doFinal((str + Secret.MAGIC).getBytes("UTF-8"))))
+ }
+
+ String encryptNew(str) {
+ return Secret.fromString(str).encryptedValue
+ }
+
+ /**
+ * Directory rewrite and recursion detection
+ */
+ @Test
+ void recursionDetection() {
+ def backup = createTempDir()
+ def sw = new SecretRewriter(backup);
+ def st = StreamTaskListener.fromStdout()
+
+ def o = encryptOld("Hello world")
+ def n = encryptNew("Hello world")
+ def payload = "$o"
+ def answer = "$n"
+
+ // set up some directories with stuff
+ def t = createTempDir()
+ def dirs = ["a", "b", "c", "c/d", "c/d/e"]
+ dirs.each { p ->
+ def d = new File(t, p)
+ d.mkdir()
+ new File(d,"foo.xml").text = payload
+ }
+
+ // stuff outside
+ def t2 = createTempDir()
+ new File(t2,"foo.xml").text = payload
+
+ // some recursions as well as valid symlinks
+ new FilePath(t).child("c/symlink").symlinkTo("..",st)
+ new FilePath(t).child("b/symlink").symlinkTo(".",st)
+ new FilePath(t).child("a/symlink").symlinkTo(t2.absolutePath,st)
+
+ assert 6==sw.rewriteRecursive(t, st)
+
+ dirs.each { p->
+ assert new File(t,"$p/foo.xml").text.trim()==answer
+ assert new File(backup,"$p/foo.xml").text.trim()==payload
+ }
+
+ // t2 is only reachable by following a symlink. this should be covered, too
+ assert new File(t2,"foo.xml").text.trim()==answer.trim();
+ }
+
+}
diff --git a/core/src/test/java/hudson/util/SecretTest.groovy b/core/src/test/java/hudson/util/SecretTest.groovy
new file mode 100644
index 0000000000000000000000000000000000000000..e6532dd8f94bbf8d4e7d1a440260bc146095db8f
--- /dev/null
+++ b/core/src/test/java/hudson/util/SecretTest.groovy
@@ -0,0 +1,104 @@
+/*
+ * The MIT License
+ *
+ * Copyright (c) 2004-2010, Sun Microsystems, Inc., Kohsuke Kawaguchi
+ *
+ * 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 hudson.util
+
+import com.trilead.ssh2.crypto.Base64;
+import jenkins.model.Jenkins
+import jenkins.security.ConfidentialStoreRule;
+import org.junit.Rule
+import org.junit.Test
+
+import javax.crypto.Cipher;
+
+/**
+ * @author Kohsuke Kawaguchi
+ */
+public class SecretTest {
+ @Rule
+ public ConfidentialStoreRule confidentialStore = new ConfidentialStoreRule()
+
+ @Rule
+ public MockSecretRule mockSecretRule = new MockSecretRule()
+
+ @Test
+ void testEncrypt() {
+ def secret = Secret.fromString("abc");
+ assert "abc"==secret.plainText;
+
+ // make sure we got some encryption going
+ println secret.encryptedValue;
+ assert !"abc".equals(secret.encryptedValue);
+
+ // can we round trip?
+ assert secret==Secret.fromString(secret.encryptedValue);
+ }
+
+ @Test
+ void testDecrypt() {
+ assert "abc"==Secret.toString(Secret.fromString("abc"))
+ }
+
+ @Test
+ void testSerialization() {
+ def s = Secret.fromString("Mr.Jenkins");
+ def xml = Jenkins.XSTREAM.toXML(s);
+ assert !xml.contains(s.plainText)
+ assert xml.contains(s.encryptedValue)
+
+ def o = Jenkins.XSTREAM.fromXML(xml);
+ assert o==s : xml;
+ }
+
+ public static class Foo {
+ Secret password;
+ }
+
+ /**
+ * Makes sure the serialization form is backward compatible with String.
+ */
+ @Test
+ void testCompatibilityFromString() {
+ def tagName = Foo.class.name.replace("\$","_-");
+ def xml = "<$tagName>secret$tagName>";
+ def foo = new Foo();
+ Jenkins.XSTREAM.fromXML(xml, foo);
+ assert "secret"==Secret.toString(foo.password)
+ }
+
+ /**
+ * Secret persisted with Jenkins.getSecretKey() should still decrypt OK.
+ */
+ @Test
+ void migrationFromLegacyKeyToConfidentialStore() {
+ def legacy = Secret.legacyKey
+ ["Hello world","","\u0000unprintable"].each { str ->
+ def cipher = Secret.getCipher("AES");
+ cipher.init(Cipher.ENCRYPT_MODE, legacy);
+ def old = new String(Base64.encode(cipher.doFinal((str + Secret.MAGIC).getBytes("UTF-8"))))
+ def s = Secret.fromString(old)
+ assert s.plainText==str : "secret by the old key should decrypt"
+ assert s.encryptedValue!=old : "but when encrypting, ConfidentialKey should be in use"
+ }
+ }
+}
diff --git a/core/src/test/java/hudson/util/SecretTest.java b/core/src/test/java/hudson/util/SecretTest.java
deleted file mode 100644
index 24102d358f6e2527bf5644e767b266fd29196a6c..0000000000000000000000000000000000000000
--- a/core/src/test/java/hudson/util/SecretTest.java
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- * The MIT License
- *
- * Copyright (c) 2004-2010, Sun Microsystems, Inc., Kohsuke Kawaguchi
- *
- * 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 hudson.util;
-
-import jenkins.model.Jenkins;
-import junit.framework.TestCase;
-
-import java.security.SecureRandom;
-
-import hudson.Util;
-
-/**
- * @author Kohsuke Kawaguchi
- */
-public class SecretTest extends TestCase {
- @Override protected void setUp() throws Exception {
- SecureRandom sr = new SecureRandom();
- byte[] random = new byte[32];
- sr.nextBytes(random);
- Secret.SECRET = Util.toHexString(random);
-
- }
-
- @Override protected void tearDown() throws Exception {
- Secret.SECRET = null;
- }
-
- public void testEncrypt() {
- Secret secret = Secret.fromString("abc");
- assertEquals("abc",secret.getPlainText());
-
- // make sure we got some encryption going
- System.out.println(secret.getEncryptedValue());
- assertTrue(!"abc".equals(secret.getEncryptedValue()));
-
- // can we round trip?
- assertEquals(secret,Secret.fromString(secret.getEncryptedValue()));
- }
-
- public void testDecrypt() {
- assertEquals("abc",Secret.toString(Secret.fromString("abc")));
- }
-
- public void testSerialization() {
- Secret s = Secret.fromString("Mr.Hudson");
- String xml = Jenkins.XSTREAM.toXML(s);
- assertTrue(xml, !xml.contains(s.getPlainText()));
- assertTrue(xml, xml.contains(s.getEncryptedValue()));
- Object o = Jenkins.XSTREAM.fromXML(xml);
- assertEquals(xml, o, s);
- }
-
- public static class Foo {
- Secret password;
- }
-
- /**
- * Makes sure the serialization form is backward compatible with String.
- */
- public void testCompatibilityFromString() {
- String tagName = Foo.class.getName().replace("$","_-");
- String xml = "<"+tagName+">secret"+tagName+">";
- Foo foo = new Foo();
- Jenkins.XSTREAM.fromXML(xml, foo);
- assertEquals("secret",Secret.toString(foo.password));
- }
-}
diff --git a/core/src/test/java/jenkins/security/ConfidentialStoreRule.java b/core/src/test/java/jenkins/security/ConfidentialStoreRule.java
new file mode 100644
index 0000000000000000000000000000000000000000..6f940b29182a1dcb1be2378f1e8f9837fff15ffd
--- /dev/null
+++ b/core/src/test/java/jenkins/security/ConfidentialStoreRule.java
@@ -0,0 +1,37 @@
+package jenkins.security;
+
+import hudson.Util;
+import org.junit.rules.ExternalResource;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Test rule that injects a temporary {@link DefaultConfidentialStore}
+ * @author Kohsuke Kawaguchi
+ */
+public class ConfidentialStoreRule extends ExternalResource {
+ public ConfidentialStore store;
+ public File tmp;
+
+ @Override
+ protected void before() throws Throwable {
+ tmp = Util.createTempDir();
+ store = new DefaultConfidentialStore(tmp);
+ ConfidentialStore.TEST.set(store);
+ }
+
+ @Override
+ protected void after() {
+ ConfidentialStore.TEST.set(null);
+ try {
+ Util.deleteRecursive(tmp);
+ } catch (IOException e) {
+ throw new Error(e);
+ }
+ }
+
+ static {
+ ConfidentialStore.TEST = new ThreadLocal();
+ }
+}
diff --git a/core/src/test/java/jenkins/security/CryptoConfidentialKeyTest.groovy b/core/src/test/java/jenkins/security/CryptoConfidentialKeyTest.groovy
new file mode 100644
index 0000000000000000000000000000000000000000..e3816a342c4e76e04469993ab0a9ea61a3af57c3
--- /dev/null
+++ b/core/src/test/java/jenkins/security/CryptoConfidentialKeyTest.groovy
@@ -0,0 +1,37 @@
+package jenkins.security
+
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ *
+ *
+ * @author Kohsuke Kawaguchi
+ */
+class CryptoConfidentialKeyTest {
+ @Rule
+ public ConfidentialStoreRule store = new ConfidentialStoreRule()
+
+ def key = new CryptoConfidentialKey("test")
+
+ @Test
+ void decryptGetsPlainTextBack() {
+ ["Hello world","","\u0000"].each { str ->
+ assert key.decrypt().doFinal(key.encrypt().doFinal(str.bytes))==str.bytes
+ }
+ }
+
+ @Test
+ void multipleEncryptsAreIdempotent() {
+ def str = "Hello world".bytes
+ assert key.encrypt().doFinal(str)==key.encrypt().doFinal(str)
+ }
+
+ @Test
+ void loadingExistingKey() {
+ def key2 = new CryptoConfidentialKey("test") // this will cause the key to be loaded from the disk
+ ["Hello world","","\u0000"].each { str ->
+ assert key2.decrypt().doFinal(key.encrypt().doFinal(str.bytes))==str.bytes
+ }
+ }
+}
diff --git a/core/src/test/java/jenkins/security/DefaultConfidentialStoreTest.groovy b/core/src/test/java/jenkins/security/DefaultConfidentialStoreTest.groovy
new file mode 100644
index 0000000000000000000000000000000000000000..83cdaf218cb348d23c0ed20d3e1709b87545d94e
--- /dev/null
+++ b/core/src/test/java/jenkins/security/DefaultConfidentialStoreTest.groovy
@@ -0,0 +1,54 @@
+package jenkins.security
+
+import hudson.FilePath
+import hudson.Functions
+import hudson.Util
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+
+/**
+ * @author Kohsuke Kawaguchi
+ */
+public class DefaultConfidentialStoreTest {
+
+ def tmp;
+
+ @Before
+ void setup() {
+ tmp = Util.createTempDir()
+ }
+
+ @After
+ void tearDown() {
+ Util.deleteRecursive(tmp)
+ }
+
+ @Test
+ void roundtrip() {
+ tmp.deleteDir() // let ConfidentialStore create a directory
+
+ def store = new DefaultConfidentialStore(tmp);
+ def key = new ConfidentialKey("test") {};
+
+ // basic roundtrip
+ def str = "Hello world!"
+ store.store(key, str.bytes)
+ assert new String(store.load(key))==str
+
+ // data storage should have some stuff
+ assert new File(tmp,"test").exists()
+ assert new File(tmp,"master.key").exists()
+
+ assert !new File(tmp,"test").text.contains("Hello") // the data shouldn't be a plain text, obviously
+
+ if (!Functions.isWindows())
+ assert (new FilePath(tmp).mode()&0777) == 0700 // should be read only
+
+ // if the master key changes, we should gracefully fail to load the store
+ new File(tmp,"master.key").delete()
+ def store2 = new DefaultConfidentialStore(tmp)
+ assert new File(tmp,"master.key").exists() // we should have a new key now
+ assert store2.load(key)==null;
+ }
+}
diff --git a/core/src/test/java/jenkins/security/HMACConfidentialKeyTest.groovy b/core/src/test/java/jenkins/security/HMACConfidentialKeyTest.groovy
new file mode 100644
index 0000000000000000000000000000000000000000..118ff5b904e12e73d6bcf784e44a5b2c61a4b23e
--- /dev/null
+++ b/core/src/test/java/jenkins/security/HMACConfidentialKeyTest.groovy
@@ -0,0 +1,39 @@
+package jenkins.security
+
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ *
+ *
+ * @author Kohsuke Kawaguchi
+ */
+class HMACConfidentialKeyTest {
+ @Rule
+ public ConfidentialStoreRule store = new ConfidentialStoreRule()
+
+ def key = new HMACConfidentialKey("test",16)
+
+ @Test
+ void basics() {
+ def unique = [] as TreeSet
+ ["Hello world","","\u0000"].each { str ->
+ def mac = key.mac(str)
+ unique.add(mac)
+ assert mac =~ /[0-9A-Fa-f]{32}/
+ assert key.checkMac(str,mac)
+ assert !key.checkMac("garbage",mac)
+ }
+
+ assert unique.size()==3 // make sure all 3 MAC are different
+ }
+
+ @Test
+ void loadingExistingKey() {
+ // this second key of the same ID will cause it to load the key from the disk
+ def key2 = new HMACConfidentialKey("test",16)
+ ["Hello world","","\u0000"].each { str ->
+ assert key.mac(str)==key2.mac(str)
+ }
+ }
+}
diff --git a/core/src/test/java/jenkins/security/HexStringConfidentialKeyTest.groovy b/core/src/test/java/jenkins/security/HexStringConfidentialKeyTest.groovy
new file mode 100644
index 0000000000000000000000000000000000000000..7062d2326f416fc392011ca10590f7246d5d1713
--- /dev/null
+++ b/core/src/test/java/jenkins/security/HexStringConfidentialKeyTest.groovy
@@ -0,0 +1,43 @@
+package jenkins.security
+
+import org.junit.Rule
+import org.junit.Test
+
+/**
+ *
+ *
+ * @author Kohsuke Kawaguchi
+ */
+class HexStringConfidentialKeyTest {
+ @Rule
+ public ConfidentialStoreRule store = new ConfidentialStoreRule()
+
+ @Test
+ void hexStringShouldProduceHexString() {
+ def key = new HexStringConfidentialKey("test",8)
+ assert key.get() =~ /[A-Fa-f0-9]{8}/
+ }
+
+ @Test
+ void multipleGetsAreIdempotent() {
+ def key = new HexStringConfidentialKey("test",8)
+ assert key.get()==key.get()
+ }
+
+ @Test
+ void specifyLengthAndMakeSureItTakesEffect() {
+ [8,16,32,256].each { n ->
+ new HexStringConfidentialKey("test"+n,n).get().length()==n
+ }
+ }
+
+ @Test
+ void loadingExistingKey() {
+ def key1 = new HexStringConfidentialKey("test",8)
+ key1.get() // this causes the ke to be generated
+
+ // this second key of the same ID will cause it to load the key from the disk
+ def key2 = new HexStringConfidentialKey("test",8)
+ assert key1.get()==key2.get()
+ }
+}
diff --git a/debian/debian/changelog b/debian/debian/changelog
index cc7d3fc7c366807295f62cc8c5fd4a12f1d099e3..aaab65ea021b329131fab94dc7eed48464ac0358 100644
--- a/debian/debian/changelog
+++ b/debian/debian/changelog
@@ -1,3 +1,15 @@
+jenkins (1.498) unstable; urgency=low
+
+ * See http://jenkins-ci.org/changelog for more details.
+
+ -- Kohsuke Kawaguchi Mon, 07 Jan 2013 11:12:53 -0800
+
+jenkins (1.497) unstable; urgency=low
+
+ * See http://jenkins-ci.org/changelog for more details.
+
+ -- Kohsuke Kawaguchi Sun, 06 Jan 2013 13:59:18 -0800
+
jenkins (1.496) unstable; urgency=low
* See http://jenkins-ci.org/changelog for more details.
diff --git a/maven-plugin/pom.xml b/maven-plugin/pom.xml
index 5f3e4292380e0cbb092eb40282cb32ccb8f6321c..827dc4e3ed56443a6d7c9493fd68702d9bb85084 100644
--- a/maven-plugin/pom.xml
+++ b/maven-plugin/pom.xml
@@ -29,7 +29,7 @@ THE SOFTWARE.
org.jenkins-ci.main
pom
- 1.498-SNAPSHOT
+ 1.499-SNAPSHOT
maven-plugin
diff --git a/plugins/pom.xml b/plugins/pom.xml
index 5536eed8e66aeaec7f92f469a81eab24d5a4d827..2920bdf240aa3e8d3b792a99cc1bd85c2ab2c4c0 100644
--- a/plugins/pom.xml
+++ b/plugins/pom.xml
@@ -11,7 +11,7 @@
org.jenkins-ci.plugins
plugin
Jenkins plugin POM
- 1.498-SNAPSHOT
+ 1.499-SNAPSHOT
pom
org.jenkins-ci.main
maven-plugin
- 1.498-SNAPSHOT
+ 1.499-SNAPSHOT
@@ -44,25 +44,25 @@
org.jenkins-ci.main
jenkins-war
war
- 1.498-SNAPSHOT
+ 1.499-SNAPSHOT
test
org.jenkins-ci.main
jenkins-core
- 1.498-SNAPSHOT
+ 1.499-SNAPSHOT
provided
org.jenkins-ci.main
jenkins-test-harness
- 1.498-SNAPSHOT
+ 1.499-SNAPSHOT
test
org.jenkins-ci.main
ui-samples-plugin
- 1.498-SNAPSHOT
+ 1.499-SNAPSHOT
test
diff --git a/pom.xml b/pom.xml
index 2513952cfc4e6dbbf54a241fc6ccf7e2d47e85fd..6e059f728b74fa7a7159e9d373fb89f0a4e99294 100644
--- a/pom.xml
+++ b/pom.xml
@@ -33,7 +33,7 @@ THE SOFTWARE.
org.jenkins-ci.main
pom
- 1.498-SNAPSHOT
+ 1.499-SNAPSHOT
pom
Jenkins main module
@@ -452,7 +452,7 @@ THE SOFTWARE.
org.kohsuke
maven-junit-plugin
- 1.10
+ 1.11
org.jvnet.localizer
diff --git a/test/pom.xml b/test/pom.xml
index 64bdb2f39b8d0d8eb34e68a81328b053eb97abde..6dce2376a91611c4240142729e0e1acca29db48d 100644
--- a/test/pom.xml
+++ b/test/pom.xml
@@ -29,7 +29,7 @@ THE SOFTWARE.
pom
org.jenkins-ci.main
- 1.498-SNAPSHOT
+ 1.499-SNAPSHOT
org.jenkins-ci.main
jenkins-test-harness
diff --git a/test/src/main/java/hudson/util/SecretHelper.java b/test/src/main/java/hudson/util/SecretHelper.java
new file mode 100644
index 0000000000000000000000000000000000000000..b76b1f8be41cbbfe7ed76c91a86f950a2ddae0d3
--- /dev/null
+++ b/test/src/main/java/hudson/util/SecretHelper.java
@@ -0,0 +1,10 @@
+package hudson.util;
+
+/**
+ * @author Kohsuke Kawaguchi
+ */
+public class SecretHelper {
+ public static void set(String s) {
+ Secret.SECRET = s;
+ }
+}
diff --git a/test/src/test/java/hudson/bugs/JnlpAccessWithSecuredHudsonTest.java b/test/src/test/java/hudson/bugs/JnlpAccessWithSecuredHudsonTest.java
index 96c4988370d574918e6ba3e371e502237d776095..b4d43cd3b477e87f417264393c2af10d1ef3eb90 100644
--- a/test/src/test/java/hudson/bugs/JnlpAccessWithSecuredHudsonTest.java
+++ b/test/src/test/java/hudson/bugs/JnlpAccessWithSecuredHudsonTest.java
@@ -23,6 +23,7 @@
*/
package hudson.bugs;
+import com.gargoylesoftware.htmlunit.FailingHttpStatusCodeException;
import com.gargoylesoftware.htmlunit.Page;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import com.gargoylesoftware.htmlunit.xml.XmlPage;
@@ -39,6 +40,7 @@ import org.jvnet.hudson.test.HudsonTestCase;
import org.jvnet.hudson.test.recipes.PresetData;
import org.jvnet.hudson.test.recipes.PresetData.DataSet;
+import java.net.HttpURLConnection;
import java.net.URL;
import java.util.Collections;
import java.util.List;
@@ -58,7 +60,7 @@ public class JnlpAccessWithSecuredHudsonTest extends HudsonTestCase {
return new DumbSlave(name,"",System.getProperty("java.io.tmpdir")+'/'+name,"2", Mode.NORMAL, "", new JNLPLauncher(), RetentionStrategy.INSTANCE, Collections.EMPTY_LIST);
}
- @PresetData(DataSet.NO_ANONYMOUS_READACCESS)
+ @PresetData(DataSet.ANONYMOUS_READONLY)
@Email("http://www.nabble.com/Launching-slave-by-JNLP-with-Active-Directory-plugin-and-matrix-security-problem-td18980323.html")
public void test() throws Exception {
jenkins.setNodes(Collections.singletonList(createNewJnlpSlave("test")));
@@ -80,5 +82,13 @@ public class JnlpAccessWithSecuredHudsonTest extends HudsonTestCase {
Page jarResource = jnlpAgent.getPage(url);
assertTrue(jarResource.getWebResponse().getContentType().toLowerCase(Locale.ENGLISH).startsWith("application/"));
}
+
+
+ try {
+ jnlp = (XmlPage) jnlpAgent.goTo("computer/test/slave-agent.jnlp", "application/x-java-jnlp-file");
+ fail("anonymous users must not be able to get secrets");
+ } catch (FailingHttpStatusCodeException x) {
+ assertEquals(HttpURLConnection.HTTP_FORBIDDEN, x.getStatusCode());
+ }
}
}
diff --git a/test/src/test/java/jenkins/security/ApiTokenPropertyTest.java b/test/src/test/java/jenkins/security/ApiTokenPropertyTest.java
index 1a71272d100517a7bc89e895284d4ba85ea61370..cf4e85dcfe4314820e2768ab0f3b440d5febb225 100644
--- a/test/src/test/java/jenkins/security/ApiTokenPropertyTest.java
+++ b/test/src/test/java/jenkins/security/ApiTokenPropertyTest.java
@@ -3,7 +3,9 @@ package jenkins.security;
import com.gargoylesoftware.htmlunit.HttpWebConnection;
import com.gargoylesoftware.htmlunit.html.HtmlForm;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
+import hudson.Util;
import hudson.model.User;
+import jenkins.model.Jenkins;
import org.apache.commons.httpclient.Credentials;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.UsernamePasswordCredentials;
@@ -60,4 +62,27 @@ public class ApiTokenPropertyTest extends HudsonTestCase {
}
}));
}
+
+ public void testSecurity49Upgrade() throws Exception {
+ jenkins.setSecurityRealm(createDummySecurityRealm());
+ User u = User.get("foo");
+ String historicalInitialValue = Util.getDigestOf(Jenkins.getInstance().getSecretKey() + ":" + u.getId());
+
+ // we won't accept historically used initial value as it may be compromised
+ ApiTokenProperty t = new ApiTokenProperty(historicalInitialValue);
+ u.addProperty(t);
+ String apiToken1 = t.getApiToken();
+ assertFalse(apiToken1.equals(Util.getDigestOf(historicalInitialValue)));
+
+ // the replacement for the compromised value must be consistent and cannot be random
+ ApiTokenProperty t2 = new ApiTokenProperty(historicalInitialValue);
+ u.addProperty(t2);
+ assertEquals(apiToken1,t2.getApiToken());
+
+ // any other value is OK. those are changed values
+ t = new ApiTokenProperty(historicalInitialValue+"somethingElse");
+ u.addProperty(t);
+ assertTrue(t.getApiToken().equals(Util.getDigestOf(historicalInitialValue+"somethingElse")));
+
+ }
}
diff --git a/test/src/test/java/jenkins/security/RekeySecretAdminMonitorTest.java b/test/src/test/java/jenkins/security/RekeySecretAdminMonitorTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..270b738697d0b89bfb8f8f8a4a226aeb3c51cb19
--- /dev/null
+++ b/test/src/test/java/jenkins/security/RekeySecretAdminMonitorTest.java
@@ -0,0 +1,157 @@
+package jenkins.security;
+
+import com.gargoylesoftware.htmlunit.ElementNotFoundException;
+import com.gargoylesoftware.htmlunit.html.HtmlButton;
+import com.gargoylesoftware.htmlunit.html.HtmlForm;
+import com.gargoylesoftware.htmlunit.html.HtmlPage;
+import com.trilead.ssh2.crypto.Base64;
+import hudson.FilePath;
+import hudson.Util;
+import hudson.util.Secret;
+import hudson.util.SecretHelper;
+import org.apache.commons.io.FileUtils;
+import org.jvnet.hudson.test.HudsonTestCase;
+import org.jvnet.hudson.test.recipes.Recipe.Runner;
+import org.xml.sax.SAXException;
+
+import javax.crypto.Cipher;
+import javax.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.lang.annotation.Annotation;
+
+/**
+ * @author Kohsuke Kawaguchi
+ */
+public class RekeySecretAdminMonitorTest extends HudsonTestCase {
+ @Inject
+ RekeySecretAdminMonitor monitor;
+
+ @Override
+ protected void setUp() throws Exception {
+ SecretHelper.set(TEST_KEY);
+ super.setUp();
+ monitor.setNeeded();
+ }
+
+ @Override
+ protected void tearDown() throws Exception {
+ SecretHelper.set(null);
+ super.tearDown();
+ }
+
+ @Override
+ protected void recipe() throws Exception {
+ super.recipe();
+ recipes.add(new Runner() {
+ @Override
+ public void setup(HudsonTestCase testCase, Annotation recipe) throws Exception {
+ }
+
+ @Override
+ public void decorateHome(HudsonTestCase testCase, File home) throws Exception {
+ if (getName().endsWith("testScanOnBoot")) {
+ // schedule a scan on boot
+ File f = new File(home, RekeySecretAdminMonitor.class.getName() + "/scanOnBoot");
+ f.getParentFile().mkdirs();
+ new FilePath(f).touch(0);
+
+ // and stage some data
+ putSomeOldData(home);
+ }
+ }
+
+ @Override
+ public void tearDown(HudsonTestCase testCase, Annotation recipe) throws Exception {
+ }
+ });
+ }
+
+
+ private void putSomeOldData(File dir) throws Exception {
+ File xml = new File(dir, "foo.xml");
+ FileUtils.writeStringToFile(xml,"" + encryptOld(TEST_KEY) + "");
+ }
+
+ private void verifyRewrite(File dir) throws Exception {
+ File xml = new File(dir, "foo.xml");
+ assertEquals("" + encryptNew(TEST_KEY) + "".trim(),
+ FileUtils.readFileToString(xml).trim());
+ }
+
+ public void testBasicWorkflow() throws Exception {
+ putSomeOldData(jenkins.getRootDir());
+
+ WebClient wc = createWebClient();
+
+ // one should see the warning. try scheduling it
+ assertTrue(!monitor.isScanOnBoot());
+ HtmlForm form = getRekeyForm(wc);
+ submit(form, "schedule");
+ assertTrue(monitor.isScanOnBoot());
+ form = getRekeyForm(wc);
+ assertTrue(getButton(form, 1).isDisabled());
+
+ // run it now
+ assertTrue(!monitor.getLogFile().exists());
+ submit(form, "background");
+ assertTrue(monitor.getLogFile().exists());
+
+ // should be no warning/error now
+ HtmlPage manage = wc.goTo("/manage");
+ assertEquals(0,manage.selectNodes("//*[class='error']").size());
+ assertEquals(0,manage.selectNodes("//*[class='warning']").size());
+
+ // and the data should be rewritten
+ verifyRewrite(jenkins.getRootDir());
+ assertTrue(monitor.isDone());
+
+ // dismiss and the message will be gone
+ assertTrue(monitor.isEnabled());
+ form = getRekeyForm(wc);
+ submit(form, "dismiss");
+ assertFalse(monitor.isEnabled());
+ try {
+ getRekeyForm(wc);
+ fail();
+ } catch (ElementNotFoundException e) {
+ // expected
+ }
+ }
+
+ private HtmlForm getRekeyForm(WebClient wc) throws IOException, SAXException {
+ return wc.goTo("/manage").getFormByName("rekey");
+ }
+
+ private HtmlButton getButton(HtmlForm form, int index) {
+ return form.getHtmlElementsByTagName("button").get(index);
+ }
+
+ public void testScanOnBoot() throws Exception {
+ WebClient wc = createWebClient();
+
+ // scan on boot should have run the scan
+ assertTrue(monitor.getLogFile().exists());
+ assertTrue("scan on boot should have turned this off",!monitor.isScanOnBoot());
+
+ // and data should be migrated
+ verifyRewrite(jenkins.getRootDir());
+
+ // should be no warning/error now
+ HtmlPage manage = wc.goTo("/manage");
+ assertEquals(0,manage.selectNodes("//*[class='error']").size());
+ assertEquals(0,manage.selectNodes("//*[class='warning']").size());
+ }
+
+ private String encryptOld(String str) throws Exception {
+ Cipher cipher = Secret.getCipher("AES");
+ cipher.init(Cipher.ENCRYPT_MODE, Util.toAes128Key(TEST_KEY));
+ return new String(Base64.encode(cipher.doFinal((str + "::::MAGIC::::").getBytes("UTF-8"))));
+ }
+
+ private String encryptNew(String str) {
+ return Secret.fromString(str).getEncryptedValue();
+ }
+
+ private static final String TEST_KEY = "superDuperSecretWasNotSoSecretAfterAll";
+}
diff --git a/ui-samples-plugin/pom.xml b/ui-samples-plugin/pom.xml
index ac067ebb6afee9d4df0d788f7e228d7ac0918fa7..e8e6f4e422579756a2c213982ccb6e940ce62130 100644
--- a/ui-samples-plugin/pom.xml
+++ b/ui-samples-plugin/pom.xml
@@ -29,7 +29,7 @@ THE SOFTWARE.
org.jenkins-ci.main
pom
- 1.498-SNAPSHOT
+ 1.499-SNAPSHOT
ui-samples-plugin
diff --git a/war/pom.xml b/war/pom.xml
index 5e74b312ff4908a8e87b071617b222bc4ec29706..52e830cc559281c32ff189ed4c6540f4186f1a45 100644
--- a/war/pom.xml
+++ b/war/pom.xml
@@ -28,7 +28,7 @@ THE SOFTWARE.
org.jenkins-ci.main
pom
- 1.498-SNAPSHOT
+ 1.499-SNAPSHOT
../pom.xml