Skip to content

Commit

Permalink
[SECURITY-49] added a tool to re-key secrets
Browse files Browse the repository at this point in the history
As an AdministrativeMonitor, it shows up in the manage Jenkins UI, and
allows the administrator to run a re-keying operation.
(cherry picked from commit 4895eaa)
  • Loading branch information
kohsuke committed Jan 5, 2013
1 parent 7983ae3 commit 9fb6c2c
Show file tree
Hide file tree
Showing 11 changed files with 859 additions and 22 deletions.
8 changes: 3 additions & 5 deletions core/src/main/java/hudson/util/Secret.java
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
}
}

Expand Down
223 changes: 223 additions & 0 deletions 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<String> callstack = new HashSet<String>();

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<s.length(); i++)
if (!isBase64(s.charAt(i)))
return false;
return true;
}

private static final boolean[] IS_BASE64 = new boolean[128];
static {
String chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=";
for (int i=0; i<chars.length();i++)
IS_BASE64[chars.charAt(i)] = true;
}
}

0 comments on commit 9fb6c2c

Please sign in to comment.