From 4895eaafca468b7f0f1a3166b2fca7414f0d5da5 Mon Sep 17 00:00:00 2001 From: Kohsuke Kawaguchi Date: Thu, 3 Jan 2013 13:43:26 -0800 Subject: [PATCH] [SECURITY-49] added a tool to re-key secrets As an AdministrativeMonitor, it shows up in the manage Jenkins UI, and allows the administrator to run a re-keying operation. --- core/src/main/java/hudson/util/Secret.java | 8 +- .../main/java/hudson/util/SecretRewriter.java | 223 ++++++++++++++++++ .../security/RekeySecretAdminMonitor.java | 196 +++++++++++++++ .../RekeySecretAdminMonitor/log.groovy | 45 ++++ .../RekeySecretAdminMonitor/message.groovy | 59 +++++ .../message.properties | 17 ++ .../test/java/hudson/util/MockSecretRule.java | 33 +++ .../hudson/util/SecretRewriterTest.groovy | 114 +++++++++ .../test/java/hudson/util/SecretTest.groovy | 19 +- .../main/java/hudson/util/SecretHelper.java | 10 + .../security/RekeySecretAdminMonitorTest.java | 157 ++++++++++++ 11 files changed, 859 insertions(+), 22 deletions(-) create mode 100644 core/src/main/java/hudson/util/SecretRewriter.java create mode 100644 core/src/main/java/jenkins/security/RekeySecretAdminMonitor.java create mode 100644 core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/log.groovy create mode 100644 core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.groovy create mode 100644 core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/message.properties create mode 100644 core/src/test/java/hudson/util/MockSecretRule.java create mode 100644 core/src/test/java/hudson/util/SecretRewriterTest.groovy create mode 100644 test/src/main/java/hudson/util/SecretHelper.java create mode 100644 test/src/test/java/jenkins/security/RekeySecretAdminMonitorTest.java diff --git a/core/src/main/java/hudson/util/Secret.java b/core/src/main/java/hudson/util/Secret.java index 32cba8dce5cf..90144ed6afd8 100644 --- a/core/src/main/java/hudson/util/Secret.java +++ b/core/src/main/java/hudson/util/Secret.java @@ -34,8 +34,6 @@ import jenkins.security.CryptoConfidentialKey; import org.kohsuke.stapler.Stapler; -import javax.crypto.BadPaddingException; -import javax.crypto.IllegalBlockSizeException; import javax.crypto.SecretKey; import javax.crypto.Cipher; import java.io.Serializable; @@ -105,7 +103,7 @@ public int hashCode() { * 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. */ - /*package*/ static SecretKey getLegacyKey() throws UnsupportedEncodingException, GeneralSecurityException { + /*package*/ static SecretKey getLegacyKey() throws GeneralSecurityException { String secret = SECRET; if(secret==null) return Jenkins.getInstance().getSecretKeyAsAES128(); return Util.toAes128Key(secret); @@ -152,14 +150,14 @@ public static Secret decrypt(String data) { } } - private static Secret tryDecrypt(Cipher cipher, byte[] in) throws UnsupportedEncodingException { + /*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; + return null; // if the key doesn't match with the bytes, it can result in BadPaddingException } } 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 000000000000..7e24dfda9d7b --- /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(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/resources/jenkins/security/RekeySecretAdminMonitor/log.groovy b/core/src/main/resources/jenkins/security/RekeySecretAdminMonitor/log.groovy new file mode 100644 index 000000000000..ee6eb6e68130 --- /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 000000000000..ec3eae3993a2 --- /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 000000000000..2daf3f7e8fb0 --- /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 000000000000..f3c11ea381de --- /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 000000000000..13522c22ad80 --- /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 index ef9385029210..e6532dd8f94b 100644 --- a/core/src/test/java/hudson/util/SecretTest.groovy +++ b/core/src/test/java/hudson/util/SecretTest.groovy @@ -26,15 +26,10 @@ package hudson.util import com.trilead.ssh2.crypto.Base64; import jenkins.model.Jenkins import jenkins.security.ConfidentialStoreRule; -import org.junit.After -import org.junit.Before import org.junit.Rule import org.junit.Test import javax.crypto.Cipher; -import java.security.SecureRandom; - -import hudson.Util; /** * @author Kohsuke Kawaguchi @@ -43,18 +38,8 @@ public class SecretTest { @Rule public ConfidentialStoreRule confidentialStore = new ConfidentialStoreRule() - @Test @Before - void setUp() { - def sr = new SecureRandom(); - byte[] random = new byte[32]; - sr.nextBytes(random); - Secret.SECRET = Util.toHexString(random); - } - - @Test @After - void tearDown() { - Secret.SECRET = null; - } + @Rule + public MockSecretRule mockSecretRule = new MockSecretRule() @Test void testEncrypt() { 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 000000000000..b76b1f8be41c --- /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/jenkins/security/RekeySecretAdminMonitorTest.java b/test/src/test/java/jenkins/security/RekeySecretAdminMonitorTest.java new file mode 100644 index 000000000000..270b738697d0 --- /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"; +}