From b423580c552ff2c2d47829b0e1632a77958fec68 Mon Sep 17 00:00:00 2001
From: James Moger <james.moger@gitblit.com>
Date: Fri, 23 Nov 2012 10:31:58 -0500
Subject: [PATCH] X509 certificate generation utilities for CA, web, and client certificates

---
 src/com/gitblit/utils/X509Utils.java       |  991 +++++++++++++++++++++++++++++++++++++++++++++++++
 tests/com/gitblit/tests/X509UtilsTest.java |  172 ++++++++
 src/com/gitblit/utils/FileUtils.java       |   19 
 3 files changed, 1,182 insertions(+), 0 deletions(-)

diff --git a/src/com/gitblit/utils/FileUtils.java b/src/com/gitblit/utils/FileUtils.java
index cba88d0..0b8aeb4 100644
--- a/src/com/gitblit/utils/FileUtils.java
+++ b/src/com/gitblit/utils/FileUtils.java
@@ -99,6 +99,25 @@
 		}
 		return defaultValue;
 	}
+	
+	/**
+	 * Returns the byte [] content of the specified file.
+	 * 
+	 * @param file
+	 * @return the byte content of the file
+	 */
+	public static byte [] readContent(File file) {
+		byte [] buffer = new byte[(int) file.length()];
+		try {
+			BufferedInputStream is = new BufferedInputStream(new FileInputStream(file));
+			is.read(buffer,  0,  buffer.length);
+			is.close();
+		} catch (Throwable t) {
+			System.err.println("Failed to read byte content of " + file.getAbsolutePath());
+			t.printStackTrace();
+		}
+		return buffer;
+	}
 
 	/**
 	 * Returns the string content of the specified file.
diff --git a/src/com/gitblit/utils/X509Utils.java b/src/com/gitblit/utils/X509Utils.java
new file mode 100644
index 0000000..e6ffec1
--- /dev/null
+++ b/src/com/gitblit/utils/X509Utils.java
@@ -0,0 +1,991 @@
+/*
+ * Copyright 2012 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.utils;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.lang.reflect.Field;
+import java.math.BigInteger;
+import java.security.InvalidKeyException;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.KeyStore;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.security.SecureRandom;
+import java.security.Security;
+import java.security.SignatureException;
+import java.security.cert.CertPathBuilder;
+import java.security.cert.CertPathBuilderException;
+import java.security.cert.CertStore;
+import java.security.cert.Certificate;
+import java.security.cert.CollectionCertStoreParameters;
+import java.security.cert.PKIXBuilderParameters;
+import java.security.cert.PKIXCertPathBuilderResult;
+import java.security.cert.TrustAnchor;
+import java.security.cert.X509CRL;
+import java.security.cert.X509CertSelector;
+import java.security.cert.X509Certificate;
+import java.text.MessageFormat;
+import java.text.SimpleDateFormat;
+import java.util.Arrays;
+import java.util.Calendar;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.TimeZone;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import javax.crypto.Cipher;
+
+import org.bouncycastle.asn1.ASN1ObjectIdentifier;
+import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
+import org.bouncycastle.asn1.x500.X500Name;
+import org.bouncycastle.asn1.x500.X500NameBuilder;
+import org.bouncycastle.asn1.x500.style.BCStyle;
+import org.bouncycastle.asn1.x509.BasicConstraints;
+import org.bouncycastle.asn1.x509.GeneralName;
+import org.bouncycastle.asn1.x509.GeneralNames;
+import org.bouncycastle.asn1.x509.KeyUsage;
+import org.bouncycastle.asn1.x509.X509Extension;
+import org.bouncycastle.cert.X509CRLHolder;
+import org.bouncycastle.cert.X509v2CRLBuilder;
+import org.bouncycastle.cert.X509v3CertificateBuilder;
+import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
+import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
+import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
+import org.bouncycastle.jce.PrincipalUtil;
+import org.bouncycastle.jce.interfaces.PKCS12BagAttributeCarrier;
+import org.bouncycastle.openssl.PEMWriter;
+import org.bouncycastle.operator.ContentSigner;
+import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import sun.security.x509.X509CRLImpl;
+
+import com.gitblit.Constants;
+
+/**
+ * Utility class to generate X509 certificates, keystores, and truststores.
+ * 
+ * @author James Moger
+ * 
+ */
+public class X509Utils {
+	
+	public static final String SERVER_KEY_STORE = "serverKeyStore.jks";
+	
+	public static final String SERVER_TRUST_STORE = "serverTrustStore.jks";
+
+	public static final String CERTS = "certs";
+	
+	public static final String CA_KEY_STORE = "certs/caKeyStore.p12";
+
+	public static final String CA_REVOCATION_LIST = "certs/caRevocationList.crl";
+	
+	public static final String CA_CONFIG = "certs/authority.conf";
+
+	public static final String CA_CN = "Gitblit Certificate Authority";
+	
+	public static final String CA_FN = CA_CN;
+
+	private static final String BC = org.bouncycastle.jce.provider.BouncyCastleProvider.PROVIDER_NAME;
+	
+	public static final boolean unlimitedStrength;
+	
+	private static final Logger logger = LoggerFactory.getLogger(X509Utils.class);
+	
+	static {
+		Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider());
+		
+		// check for JCE Unlimited Strength
+		int maxKeyLen = 0;
+		try {
+			maxKeyLen = Cipher.getMaxAllowedKeyLength("AES");
+		} catch (NoSuchAlgorithmException e) {
+		}
+
+		unlimitedStrength = maxKeyLen > 128;
+		if (unlimitedStrength) {
+			logger.info("Using JCE Unlimited Strength Jurisdiction Policy files");
+		} else {
+			logger.info("Using JCE Standard Encryption Policy files, encryption key lengths will be limited");
+		}
+	}
+	
+	public static enum RevocationReason {
+		// https://en.wikipedia.org/wiki/Revocation_list
+		 unspecified, keyCompromise, caCompromise, affiliationChanged, superseded,
+		 cessationOfOperation, certificateHold, unused, removeFromCRL, privilegeWithdrawn,
+		 ACompromise;
+		 
+		 public static RevocationReason [] reasons = {
+			 	unspecified, keyCompromise, caCompromise, 
+				 affiliationChanged, superseded, cessationOfOperation, 
+				 privilegeWithdrawn };
+		 
+		 @Override
+		 public String toString() {
+			 return name() +  " (" + ordinal() + ")";
+		 }
+	}
+	
+	public static class X509Metadata {
+
+		// map for distinguished name OIDs
+		public final Map<String, String> oids;
+
+		// CN in distingiushed name
+		public final String commonName;
+		
+		// password for store
+		public final String password;
+		
+		// password hint for README in bundle
+		public String passwordHint;
+
+		// E or EMAILADDRESS in distinguished name
+		public String emailAddress;
+
+		// start date of generated certificate
+		public Date notBefore;
+		
+		// expiraiton date of generated certificate
+		public Date notAfter;
+		
+		// hostname of server for which certificate is generated
+		public String serverHostname;
+		
+		// displayname of user for README in bundle
+		public String userDisplayname;
+
+		public X509Metadata(String cn, String pwd) {
+			if (StringUtils.isEmpty(cn)) {
+				throw new RuntimeException("Common name required!");
+			}
+			if (StringUtils.isEmpty(pwd)) {
+				throw new RuntimeException("Password required!");
+			}
+
+			commonName = cn;
+			password = pwd;
+			Calendar c = Calendar.getInstance(TimeZone.getDefault());
+			c.set(Calendar.SECOND, 0);
+			c.set(Calendar.MILLISECOND, 0);
+			notBefore = c.getTime();
+			c.add(Calendar.YEAR, 1);
+			c.add(Calendar.DATE, 1);
+			notAfter = c.getTime();
+			oids = new HashMap<String, String>();
+		}
+		
+		public X509Metadata clone(String commonName, String password) {
+			X509Metadata clone = new X509Metadata(commonName, password);
+			clone.emailAddress = emailAddress;
+			clone.notBefore = notBefore;
+			clone.notAfter = notAfter;
+			clone.oids.putAll(oids);
+			clone.passwordHint = passwordHint;
+			clone.serverHostname = serverHostname;
+			clone.userDisplayname = userDisplayname;
+			return clone;
+		}
+	}
+	
+	/**
+	 * Prepare all the certificates and stores necessary for a Gitblit GO server.
+	 * 
+	 * @param metadata
+	 * @param folder
+	 * @param logger
+	 */
+	public static void prepareX509Infrastructure(X509Metadata metadata, File folder) {
+		// make the specified folder, if necessary
+		folder.mkdirs();
+		
+		// Gitblit CA certificate
+		File caKeyStore = new File(folder, CA_KEY_STORE);			
+		if (!caKeyStore.exists()) {
+			logger.info(MessageFormat.format("Generating {0} ({1})", CA_CN, caKeyStore.getAbsolutePath()));
+			X509Certificate caCert = newCertificateAuthority(metadata, caKeyStore);
+			saveCertificate(caCert, new File(caKeyStore.getParentFile(), "ca.cer"));
+		}
+
+		// rename the old keystore to the new name
+		File oldKeyStore = new File(folder, "keystore");
+		if (oldKeyStore.exists()) {
+			oldKeyStore.renameTo(new File(folder, SERVER_KEY_STORE));
+			logger.info(MessageFormat.format("Renaming {0} to {1}", oldKeyStore.getName(), SERVER_KEY_STORE));				
+		}
+
+		// create web SSL certificate signed by CA
+		File serverKeyStore = new File(folder, SERVER_KEY_STORE);
+		if (!serverKeyStore.exists()) {
+			logger.info(MessageFormat.format("Generating SSL certificate for {0} signed by {1} ({2})", metadata.commonName, CA_CN, serverKeyStore.getAbsolutePath()));
+			PrivateKey caPrivateKey = getPrivateKey(CA_FN, caKeyStore, metadata.password);
+			X509Certificate caCert = getCertificate(CA_FN, caKeyStore, metadata.password);
+			newSSLCertificate(metadata, caPrivateKey, caCert, serverKeyStore);
+		}
+
+		// server certificate trust store holds trusted public certificates
+		File serverTrustStore = new File(folder, X509Utils.SERVER_TRUST_STORE);
+		if (!serverTrustStore.exists()) {
+			logger.info(MessageFormat.format("Importing {0} into trust store ({1})", CA_FN, serverTrustStore.getAbsolutePath()));
+			X509Certificate caCert = getCertificate(CA_FN, caKeyStore, metadata.password);
+			addTrustedCertificate(CA_FN, caCert, serverTrustStore, metadata.password);
+		}
+	}
+	
+	/**
+	 * Open a keystore.  Store type is determined by file extension of name. If
+	 * undetermined, JKS is assumed.  The keystore does not need to exist.
+	 *  
+	 * @param storeFile
+	 * @param storePassword
+	 * @return a KeyStore
+	 */
+	public static KeyStore openKeyStore(File storeFile, String storePassword) {
+		String lc = storeFile.getName().toLowerCase();
+		String type = "JKS";
+		String provider = null;
+		if (lc.endsWith(".p12") || lc.endsWith(".pfx")) {
+			type = "PKCS12";
+			provider = BC;
+		}
+
+		try {
+			KeyStore store;
+			if (provider == null) {
+				store = KeyStore.getInstance(type);
+			} else {
+				store = KeyStore.getInstance(type, provider);
+			}
+			if (storeFile.exists()) {
+				FileInputStream fis = null;
+				try {
+					fis = new FileInputStream(storeFile);
+					store.load(fis, storePassword.toCharArray());
+				} finally {
+					if (fis != null) {
+						fis.close();
+					}
+				}
+			} else {
+				store.load(null);
+			}
+			return store;
+		} catch (Exception e) {
+			throw new RuntimeException("Could not open keystore " + storeFile, e);
+		}
+	}
+	
+	/**
+	 * Saves the keystore to the specified file.
+	 * 
+	 * @param targetStoreFile
+	 * @param store
+	 * @param password
+	 */
+	public static void saveKeyStore(File targetStoreFile, KeyStore store, String password) {
+		File folder = targetStoreFile.getAbsoluteFile().getParentFile();
+		if (!folder.exists()) {
+			folder.mkdirs();
+		}
+		File tmpFile = new File(folder, Long.toHexString(System.currentTimeMillis()) + ".tmp");
+		FileOutputStream fos = null;
+		try {
+			fos = new FileOutputStream(tmpFile);
+			store.store(fos, password.toCharArray());
+			fos.flush();
+			fos.close();
+			if (targetStoreFile.exists()) {
+				targetStoreFile.delete();
+			}
+			tmpFile.renameTo(targetStoreFile);
+		} catch (IOException e) {
+			String message = e.getMessage().toLowerCase();
+			if (message.contains("illegal key size")) {
+				throw new RuntimeException("Illegal Key Size! You might consider installing the JCE Unlimited Strength Jurisdiction Policy files for your JVM.");
+			} else {
+				throw new RuntimeException("Could not save keystore " + targetStoreFile, e);
+			}
+		} catch (Exception e) {
+			throw new RuntimeException("Could not save keystore " + targetStoreFile, e);
+		} finally {
+			if (fos != null) {
+				try {
+					fos.close();
+				} catch (IOException e) {
+				}
+			}
+			
+			if (tmpFile.exists()) {
+				tmpFile.delete();
+			}
+		}
+	}	
+
+	/**
+	 * Retrieves the X509 certificate with the specified alias from the certificate
+	 * store.
+	 * 
+	 * @param alias
+	 * @param storeFile
+	 * @param storePassword
+	 * @return the certificate
+	 */
+	public static X509Certificate getCertificate(String alias, File storeFile, String storePassword) {
+		try {
+			KeyStore store = openKeyStore(storeFile, storePassword);
+			X509Certificate caCert = (X509Certificate) store.getCertificate(alias);
+			return caCert;
+		} catch (Exception e) {
+			throw new RuntimeException(e);
+		}
+	}
+	
+	/**
+	 * Retrieves the private key for the specified alias from the certificate
+	 * store.
+	 * 
+	 * @param alias
+	 * @param storeFile
+	 * @param storePassword
+	 * @return the private key
+	 */
+	public static PrivateKey getPrivateKey(String alias, File storeFile, String storePassword) {
+		try {
+			KeyStore store = openKeyStore(storeFile, storePassword);
+			PrivateKey key = (PrivateKey) store.getKey(alias, storePassword.toCharArray());
+			return key;
+		} catch (Exception e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	/**
+	 * Saves the certificate to the file system.  If the destination filename
+	 * ends with the pem extension, the certificate is written in the PEM format,
+	 * otherwise the certificate is written in the DER format.
+	 * 
+	 * @param cert
+	 * @param targetFile
+	 */
+	public static void saveCertificate(X509Certificate cert, File targetFile) {
+		File folder = targetFile.getAbsoluteFile().getParentFile();
+		if (!folder.exists()) {
+			folder.mkdirs();
+		}
+		File tmpFile = new File(folder, Long.toHexString(System.currentTimeMillis()) + ".tmp");
+		try {
+			boolean asPem = targetFile.getName().toLowerCase().endsWith(".pem");
+			if (asPem) {
+				// PEM encoded X509
+				PEMWriter pemWriter = null;
+				try {
+					pemWriter = new PEMWriter(new FileWriter(tmpFile));
+					pemWriter.writeObject(cert);
+					pemWriter.flush();					
+				} finally {
+					if (pemWriter != null) {
+						pemWriter.close();
+					}
+				}
+			} else {
+				// DER encoded X509
+				FileOutputStream fos = null;
+				try {
+					fos = new FileOutputStream(tmpFile);
+					fos.write(cert.getEncoded());
+					fos.flush();
+				} finally {
+					if (fos != null) {
+						fos.close();
+					}
+				}
+			}
+			
+			// rename tmp file to target
+			if (targetFile.exists()) { 
+				targetFile.delete();
+			}
+			tmpFile.renameTo(targetFile);
+		} catch (Exception e) {
+			if (tmpFile.exists()) {
+				tmpFile.delete();
+			}
+			throw new RuntimeException("Failed to save certificate " + cert.getSubjectX500Principal().getName(), e);
+		}
+	}
+	
+	/**
+	 * Generate a new keypair.
+	 * 
+	 * @return a keypair
+	 * @throws Exception
+	 */
+	private static KeyPair newKeyPair() throws Exception {
+		KeyPairGenerator kpGen = KeyPairGenerator.getInstance("RSA", BC);
+		kpGen.initialize(2048, new SecureRandom());
+		return kpGen.generateKeyPair();
+	}
+	
+	/**
+	 * Builds a distinguished name from the X509Metadata.
+	 * 
+	 * @return a DN
+	 */
+	private static X500Name buildDistinguishedName(X509Metadata metadata) {
+		X500NameBuilder dnBuilder = new X500NameBuilder(BCStyle.INSTANCE);
+		setOID(dnBuilder, metadata, "C", null);
+		setOID(dnBuilder, metadata, "ST", null);
+		setOID(dnBuilder, metadata, "L", null);
+		setOID(dnBuilder, metadata, "O", Constants.NAME);
+		setOID(dnBuilder, metadata, "OU", Constants.NAME);
+		setOID(dnBuilder, metadata, "E", metadata.emailAddress);
+		setOID(dnBuilder, metadata, "CN", metadata.commonName);		
+		X500Name dn = dnBuilder.build();
+		return dn;
+	}
+	
+	private static void setOID(X500NameBuilder dnBuilder, X509Metadata metadata,
+			String oid, String defaultValue) {
+		
+		String value = null;
+		if (metadata.oids != null && metadata.oids.containsKey(oid)) {
+			value = metadata.oids.get(oid);
+		}
+		if (StringUtils.isEmpty(value)) {
+			value = defaultValue;
+		}
+		
+		if (!StringUtils.isEmpty(value)) {
+			try {
+				Field field = BCStyle.class.getField(oid);
+				ASN1ObjectIdentifier objectId = (ASN1ObjectIdentifier) field.get(null);
+				dnBuilder.addRDN(objectId, value);
+			} catch (Exception e) {
+				logger.error(MessageFormat.format("Failed to set OID \"{0}\"!", oid) ,e);
+			}
+		}
+	}
+
+	/**
+	 * Creates a new SSL certificate signed by the CA private key and stored in
+	 * keyStore.
+	 * 
+	 * @param sslMetadata
+	 * @param caPrivateKey
+	 * @param caCert
+	 * @param targetStoreFile
+	 */
+	public static X509Certificate newSSLCertificate(X509Metadata sslMetadata, PrivateKey caPrivateKey, X509Certificate caCert, File targetStoreFile) {
+		try {
+			KeyPair pair = newKeyPair();
+
+			X500Name webDN = buildDistinguishedName(sslMetadata);
+			X500Name issuerDN = new X500Name(PrincipalUtil.getIssuerX509Principal(caCert).getName());
+			
+			X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
+					issuerDN,
+					BigInteger.valueOf(System.currentTimeMillis()), 
+					sslMetadata.notBefore,
+					sslMetadata.notAfter,
+					webDN,
+					pair.getPublic());
+			
+			JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
+			certBuilder.addExtension(X509Extension.subjectKeyIdentifier, false, extUtils.createSubjectKeyIdentifier(pair.getPublic()));
+			certBuilder.addExtension(X509Extension.basicConstraints, false, new BasicConstraints(false));
+			certBuilder.addExtension(X509Extension.authorityKeyIdentifier, false, extUtils.createAuthorityKeyIdentifier(caCert.getPublicKey()));
+
+			ContentSigner caSigner = new JcaContentSignerBuilder("SHA256WithRSAEncryption")
+					.setProvider(BC).build(caPrivateKey);
+			X509Certificate cert = new JcaX509CertificateConverter().setProvider(BC)
+					.getCertificate(certBuilder.build(caSigner));
+			
+			cert.checkValidity(new Date());
+			cert.verify(caCert.getPublicKey());
+
+			// Save to keystore
+			KeyStore serverStore = openKeyStore(targetStoreFile, sslMetadata.password);
+			serverStore.setKeyEntry(sslMetadata.commonName, pair.getPrivate(), sslMetadata.password.toCharArray(),
+					new Certificate[] { cert, caCert });
+			saveKeyStore(targetStoreFile, serverStore, sslMetadata.password);
+			
+	        log(targetStoreFile.getParentFile(), MessageFormat.format("New web certificate {0,number,0} [{1}]", cert.getSerialNumber(), webDN.toString()));
+			
+			return cert;
+		} catch (Throwable t) {
+			throw new RuntimeException("Failed to generate SSL certificate!", t);
+		}
+	}
+
+	/**
+	 * Creates a new certificate authority PKCS#12 store.  This function will
+	 * destroy any existing CA store.
+	 * 
+	 * @param metadata
+	 * @param storeFile
+	 * @param keystorePassword
+	 * @return
+	 */
+	public static X509Certificate newCertificateAuthority(X509Metadata metadata, File storeFile) {
+		try {
+			KeyPair caPair = newKeyPair();
+			
+			ContentSigner caSigner = new JcaContentSignerBuilder("SHA1WithRSA").setProvider(BC).build(caPair.getPrivate());
+			
+			// clone metadata
+			X509Metadata caMetadata = metadata.clone(CA_CN, metadata.password);
+			X500Name issuerDN = buildDistinguishedName(caMetadata);
+			
+			// Generate self-signed certificate
+			X509v3CertificateBuilder caBuilder = new JcaX509v3CertificateBuilder(
+					issuerDN,
+					BigInteger.valueOf(System.currentTimeMillis()),
+					caMetadata.notBefore,
+					caMetadata.notAfter,
+					issuerDN,
+					caPair.getPublic());
+			
+			JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
+			caBuilder.addExtension(X509Extension.subjectKeyIdentifier, false, extUtils.createSubjectKeyIdentifier(caPair.getPublic()));
+			caBuilder.addExtension(X509Extension.authorityKeyIdentifier, false, extUtils.createAuthorityKeyIdentifier(caPair.getPublic()));
+			caBuilder.addExtension(X509Extension.basicConstraints, false, new BasicConstraints(true));
+			caBuilder.addExtension(X509Extension.keyUsage, true, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyCertSign | KeyUsage.cRLSign));
+						
+			JcaX509CertificateConverter converter = new JcaX509CertificateConverter().setProvider(BC);
+			X509Certificate cert = converter.getCertificate(caBuilder.build(caSigner));
+			
+			// confirm the validity of the CA certificate
+			cert.checkValidity(new Date());
+			cert.verify(cert.getPublicKey());
+
+			// Delete existing keystore
+			if (storeFile.exists()) {
+				storeFile.delete();
+			}
+			
+			// Save private key and certificate to new keystore
+			KeyStore store = openKeyStore(storeFile, caMetadata.password);
+			store.setKeyEntry(CA_FN, caPair.getPrivate(), caMetadata.password.toCharArray(),
+					new Certificate[] { cert });
+			saveKeyStore(storeFile, store, caMetadata.password);
+			
+	        log(storeFile.getParentFile(), MessageFormat.format("New CA certificate {0,number,0} [{1}]", cert.getSerialNumber(), issuerDN.toString()));
+			return cert;
+		} catch (Throwable t) {
+			throw new RuntimeException("Failed to generate Gitblit CA certificate!", t);
+		}
+	}
+	
+	/**
+	 * Imports a certificate into the trust store.
+	 * 
+	 * @param alias
+	 * @param cert
+	 * @param storeFile
+	 * @param storePassword
+	 */
+	public static void addTrustedCertificate(String alias, X509Certificate cert, File storeFile, String storePassword) {
+		try {
+			KeyStore store = openKeyStore(storeFile, storePassword);
+			store.setCertificateEntry(alias, cert);
+			saveKeyStore(storeFile, store, storePassword);			
+		} catch (Exception e) {
+			throw new RuntimeException("Failed to import certificate into trust store " + storeFile, e);
+		}
+	}
+	
+	/**
+	 * Creates a new client certificate PKCS#12 and PEM store.  Any existing
+	 * stores are destroyed.  After generation, the certificates are bundled
+	 * into a zip file with a personalized README file.
+	 * 
+	 * The zip file reference is returned. 
+	 * 
+	 * @param clientMetadata a container for dynamic parameters needed for generation
+	 * @param caKeystoreFile
+	 * @param caKeystorePassword
+	 * @return a zip file containing the P12, PEM, and personalized README
+	 */
+	public static File newClientBundle(X509Metadata clientMetadata, File caKeystoreFile, String caKeystorePassword) {
+		try {
+			// read the Gitblit CA key and certificate
+			KeyStore store = openKeyStore(caKeystoreFile, caKeystorePassword);
+			PrivateKey caPrivateKey = (PrivateKey) store.getKey(CA_FN, caKeystorePassword.toCharArray());
+			X509Certificate caCert = (X509Certificate) store.getCertificate(CA_FN);
+			
+			// generate the P12 and PEM files
+			File targetFolder = new File(caKeystoreFile.getParentFile(), clientMetadata.commonName);
+			newClientCertificate(clientMetadata, caPrivateKey, caCert, targetFolder);
+			
+	        // process template message
+	        String readme = processTemplate(new File(caKeystoreFile.getParentFile(), "instructions.tmpl"), clientMetadata);
+	        
+	        // Create a zip bundle with the p12, pem, and a personalized readme
+	        File zipFile = new File(targetFolder, clientMetadata.commonName + ".zip");
+	        if (zipFile.exists()) {
+	        	zipFile.delete();
+	        }
+	        ZipOutputStream zos = null;
+	        try {
+	        	zos = new ZipOutputStream(new FileOutputStream(zipFile));
+	        	File p12File = new File(targetFolder, clientMetadata.commonName + ".p12");
+	        	if (p12File.exists()) {
+	        		zos.putNextEntry(new ZipEntry(p12File.getName()));
+	        		zos.write(FileUtils.readContent(p12File));
+	        		zos.closeEntry();
+	        	}	        	
+	        	File pemFile = new File(targetFolder, clientMetadata.commonName + ".pem");
+	        	if (pemFile.exists()) {
+	        		zos.putNextEntry(new ZipEntry(pemFile.getName()));
+	        		zos.write(FileUtils.readContent(pemFile));
+	        		zos.closeEntry();
+	        	}
+	        	if (readme != null) {
+	        		zos.putNextEntry(new ZipEntry("README.TXT"));
+	        		zos.write(readme.getBytes("UTF-8"));
+	        		zos.closeEntry();
+	        	}
+	        	zos.flush();
+	        } finally {
+	        	if (zos != null) {
+	        		zos.close();
+	        	}
+	        }
+	        
+			return zipFile;
+		} catch (Throwable t) {
+			throw new RuntimeException("Failed to generate client bundle!", t);
+		}
+	}
+	
+	/**
+	 * Creates a new client certificate PKCS#12 and PEM store.  Any existing
+	 * stores are destroyed.
+	 * 
+	 * @param clientMetadata a container for dynamic parameters needed for generation
+	 * @param caKeystoreFile
+	 * @param caKeystorePassword
+	 * @param targetFolder
+	 * @return
+	 */
+	public static X509Certificate newClientCertificate(X509Metadata clientMetadata,
+			PrivateKey caPrivateKey, X509Certificate caCert, File targetFolder) {
+		try {
+			KeyPair pair = newKeyPair();
+			
+			X500Name userDN = buildDistinguishedName(clientMetadata);			
+			X500Name issuerDN = new X500Name(PrincipalUtil.getIssuerX509Principal(caCert).getName());
+			
+			// create a new certificate signed by the Gitblit CA certificate
+			X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(
+					issuerDN,
+					BigInteger.valueOf(System.currentTimeMillis()),
+					clientMetadata.notBefore,
+					clientMetadata.notAfter,
+					userDN,
+					pair.getPublic());
+			
+			JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
+			certBuilder.addExtension(X509Extension.subjectKeyIdentifier, false, extUtils.createSubjectKeyIdentifier(pair.getPublic()));
+			certBuilder.addExtension(X509Extension.basicConstraints, false, new BasicConstraints(false));
+			certBuilder.addExtension(X509Extension.authorityKeyIdentifier, false, extUtils.createAuthorityKeyIdentifier(caCert.getPublicKey()));
+			certBuilder.addExtension(X509Extension.keyUsage, true, new KeyUsage(KeyUsage.keyEncipherment | KeyUsage.digitalSignature));
+			if (!StringUtils.isEmpty(clientMetadata.emailAddress)) {
+				GeneralNames subjectAltName = new GeneralNames(
+                    new GeneralName(GeneralName.rfc822Name, clientMetadata.emailAddress));
+				certBuilder.addExtension(X509Extension.subjectAlternativeName, false, subjectAltName);
+			}
+
+			ContentSigner signer = new JcaContentSignerBuilder("SHA1WithRSA").setProvider(BC).build(caPrivateKey);
+
+			X509Certificate userCert = new JcaX509CertificateConverter().setProvider(BC).getCertificate(certBuilder.build(signer));
+			PKCS12BagAttributeCarrier bagAttr = (PKCS12BagAttributeCarrier)pair.getPrivate();
+			bagAttr.setBagAttribute(PKCSObjectIdentifiers.pkcs_9_at_localKeyId,
+					extUtils.createSubjectKeyIdentifier(pair.getPublic()));
+			 
+			// confirm the validity of the user certificate
+			userCert.checkValidity();
+			userCert.verify(caCert.getPublicKey());
+			userCert.getIssuerDN().equals(caCert.getSubjectDN());
+
+	        // verify user certificate chain
+	        verifyChain(userCert, caCert);
+
+	        targetFolder.mkdirs();
+	        
+	        // save certificate, stamped with unique name
+	        String date = new SimpleDateFormat("yyyyMMdd").format(new Date());
+	        String id = date;
+	        File certFile = new File(targetFolder, id + ".cer");
+	        int count = 0;
+	        while (certFile.exists()) {
+	        	id = date + "_" + Character.toString((char) (0x61 + count));
+	        	certFile = new File(targetFolder, id + ".cer");
+	        	count++;
+	        }
+	        
+	        // save user private key, user certificate and CA certificate to a PKCS#12 store
+	        File p12File = new File(targetFolder, clientMetadata.commonName + ".p12");
+	        if (p12File.exists()) {
+	        	p12File.delete();
+	        }
+	        KeyStore userStore = openKeyStore(p12File, clientMetadata.password);
+	        userStore.setKeyEntry(MessageFormat.format("Gitblit ({0}) {1} {2}", clientMetadata.serverHostname, clientMetadata.userDisplayname, id), pair.getPrivate(), null, new Certificate [] { userCert });
+	        userStore.setCertificateEntry(MessageFormat.format("Gitblit ({0}) Certificate Authority", clientMetadata.serverHostname), caCert);	        
+	        saveKeyStore(p12File, userStore, clientMetadata.password);
+	        
+	        // save user private key, user certificate, and CA certificate to a PEM store
+	        File pemFile = new File(targetFolder, clientMetadata.commonName + ".pem");
+	        if (pemFile.exists()) {
+	        	pemFile.delete();
+	        }
+	        PEMWriter pemWriter = new PEMWriter(new FileWriter(pemFile));
+	        pemWriter.writeObject(pair.getPrivate(), "DES-EDE3-CBC", clientMetadata.password.toCharArray(), new SecureRandom());
+	        pemWriter.writeObject(userCert);
+	        pemWriter.writeObject(caCert);
+	        pemWriter.flush();
+	        pemWriter.close();
+	        
+	        // save certificate after successfully creating the key stores
+	        saveCertificate(userCert, certFile);
+	        
+	        log(targetFolder.getParentFile(), MessageFormat.format("New client certificate {0,number,0} [{1}]", userCert.getSerialNumber(), userDN.toString()));
+	        
+	        return userCert;
+		} catch (Throwable t) {
+			throw new RuntimeException("Failed to generate client certificate!", t);
+		}
+	}
+	
+	/**
+	 * Verifies a certificate's chain to ensure that it will function properly.
+	 * 
+	 * @param testCert
+	 * @param additionalCerts
+	 * @return
+	 */
+	public static PKIXCertPathBuilderResult verifyChain(X509Certificate testCert, X509Certificate... additionalCerts) {
+		try {
+			// Check for self-signed certificate
+			if (isSelfSigned(testCert)) {
+				throw new RuntimeException("The certificate is self-signed.  Nothing to verify.");
+			}
+			
+			// Prepare a set of all certificates
+			// chain builder must have all certs, including cert to validate
+			// http://stackoverflow.com/a/10788392
+			Set<X509Certificate> certs = new HashSet<X509Certificate>();
+			certs.add(testCert);
+			certs.addAll(Arrays.asList(additionalCerts));
+			
+			// Attempt to build the certification chain and verify it
+			// Create the selector that specifies the starting certificate
+			X509CertSelector selector = new X509CertSelector(); 
+		    selector.setCertificate(testCert);
+		    
+		    // Create the trust anchors (set of root CA certificates)
+		    Set<TrustAnchor> trustAnchors = new HashSet<TrustAnchor>();
+		    for (X509Certificate cert : additionalCerts) {
+		    	if (isSelfSigned(cert)) {
+		    		trustAnchors.add(new TrustAnchor(cert, null));
+		    	}
+		    }
+		    
+		    // Configure the PKIX certificate builder
+		    PKIXBuilderParameters pkixParams = new PKIXBuilderParameters(trustAnchors, selector);
+			pkixParams.setRevocationEnabled(false);
+			pkixParams.addCertStore(CertStore.getInstance("Collection", new CollectionCertStoreParameters(certs), BC));
+		
+			// Build and verify the certification chain
+			CertPathBuilder builder = CertPathBuilder.getInstance("PKIX", BC);
+			PKIXCertPathBuilderResult verifiedCertChain = (PKIXCertPathBuilderResult) builder.build(pkixParams);
+			
+			// The chain is built and verified
+			return verifiedCertChain;
+		} catch (CertPathBuilderException e) {
+			throw new RuntimeException("Error building certification path: " + testCert.getSubjectX500Principal(), e);
+		} catch (Exception e) {
+			throw new RuntimeException("Error verifying the certificate: " + testCert.getSubjectX500Principal(), e);
+		}
+	}
+	
+	/**
+	 * Checks whether given X.509 certificate is self-signed.
+	 * 
+	 * @param cert
+	 * @return true if the certificate is self-signed
+	 */
+	public static boolean isSelfSigned(X509Certificate cert) {
+		try {
+			cert.verify(cert.getPublicKey());
+			return true;
+		} catch (SignatureException e) {
+			return false;
+		} catch (InvalidKeyException e) {
+			return false;
+		} catch (Exception e) {
+			throw new RuntimeException(e);
+		}
+	}
+	
+	public static String processTemplate(File template, X509Metadata metadata) {
+		String content = null;
+		if (template.exists()) {
+			String message = FileUtils.readContent(template, "\n");
+			if (!StringUtils.isEmpty(message)) {
+				content = message;
+				content = content.replace("$serverHostname", metadata.serverHostname);
+				content = content.replace("$username", metadata.commonName);
+				content = content.replace("$userDisplayname", metadata.userDisplayname);
+				content = content.replace("$storePasswordHint", metadata.passwordHint);
+			}
+		}
+		return content;
+	}
+	
+	private static void log(File folder, String message) {
+		BufferedWriter writer = null;
+		try {
+			writer = new BufferedWriter(new FileWriter(new File(folder, "log.txt"), true));
+			writer.write(MessageFormat.format("{0,date,yyyy-MM-dd HH:mm}: {1}", new Date(), message));
+			writer.newLine();
+			writer.flush();
+		} catch (Exception e) {
+			logger.error("Failed to append log entry!", e);
+		} finally {
+			if (writer != null) {
+				try {
+					writer.close();
+				} catch (IOException e) {
+				}
+			}
+		}
+	}
+	
+	/**
+	 * Revoke a certificate.
+	 * 
+	 * @param cert
+	 * @param reason
+	 * @param caRevocationList
+	 * @param caKeystoreFile
+	 * @param caKeystorePassword
+	 * @return true if the certificate has been revoked
+	 */
+	public static boolean revoke(X509Certificate cert, RevocationReason reason,
+			File caRevocationList, File caKeystoreFile, String caKeystorePassword) {
+		try {
+			// read the Gitblit CA key and certificate
+			KeyStore store = openKeyStore(caKeystoreFile, caKeystorePassword);
+			PrivateKey caPrivateKey = (PrivateKey) store.getKey(CA_FN, caKeystorePassword.toCharArray());
+			return revoke(cert, reason, caRevocationList, caPrivateKey);
+		} catch (Exception e) {
+			logger.error(MessageFormat.format("Failed to revoke certificate {0,number,0} [{1}] in {2}",
+					cert.getSerialNumber(), cert.getSubjectDN().getName(), caRevocationList));
+		}
+		return false;
+	}
+	
+	/**
+	 * Revoke a certificate.
+	 * 
+	 * @param cert
+	 * @param reason
+	 * @param caRevocationList
+	 * @param caPrivateKey
+	 * @return true if the certificate has been revoked
+	 */
+	public static boolean revoke(X509Certificate cert, RevocationReason reason,
+			 File caRevocationList, PrivateKey caPrivateKey) {
+		try {
+			X500Name subjectDN = new X500Name(PrincipalUtil.getSubjectX509Principal(cert).getName());
+			X500Name issuerDN = new X500Name(PrincipalUtil.getIssuerX509Principal(cert).getName());
+			X509v2CRLBuilder crlBuilder = new X509v2CRLBuilder(issuerDN, new Date());
+			if (caRevocationList.exists()) {
+				byte [] data = FileUtils.readContent(caRevocationList);				
+				X509CRLHolder crl = new X509CRLHolder(data);
+				crlBuilder.addCRL(crl);
+			}
+			crlBuilder.addCRLEntry(cert.getSerialNumber(), new Date(), reason.ordinal());
+			
+			// build and sign CRL with CA private key
+			ContentSigner signer = new JcaContentSignerBuilder("SHA1WithRSA").setProvider(BC).build(caPrivateKey);
+			X509CRLHolder crl = crlBuilder.build(signer);
+			
+			File tmpFile = new File(caRevocationList.getParentFile(), Long.toHexString(System.currentTimeMillis()) + ".tmp");
+			FileOutputStream fos = null;
+			try {
+				fos = new FileOutputStream(tmpFile);
+				fos.write(crl.getEncoded());
+				fos.flush();
+				fos.close();
+				if (caRevocationList.exists()) {
+					caRevocationList.delete();
+				}
+				tmpFile.renameTo(caRevocationList);
+				
+				log(caRevocationList.getParentFile(), MessageFormat.format("Revoked certificate {0,number,0} reason: {1} [{2}]",
+						cert.getSerialNumber(), reason.toString(), subjectDN.toString()));
+			} finally {
+				if (fos != null) {
+					fos.close();
+				}
+				if (tmpFile.exists()) {
+					tmpFile.delete();
+				}
+			}
+			return true;
+		} catch (Exception e) {
+			logger.error(MessageFormat.format("Failed to revoke certificate {0,number,0} [{1}] in {2}",
+					cert.getSerialNumber(), cert.getSubjectDN().getName(), caRevocationList));
+		}
+		return false;
+	}
+	
+	/**
+	 * Returns true if the certificate has been revoked.
+	 * 
+	 * @param cert
+	 * @param caRevocationList
+	 * @return true if the certificate is revoked
+	 */
+	public static boolean isRevoked(X509Certificate cert, File caRevocationList) {
+		if (!caRevocationList.exists()) {
+			return false;
+		}
+		try {
+			byte [] data = FileUtils.readContent(caRevocationList);
+			X509CRL crl = new X509CRLImpl(data);
+			return crl.isRevoked(cert);
+		} catch (Exception e) {
+			logger.error(MessageFormat.format("Failed to check revocation status for certificate {0,number,0} [{1}] in {2}",
+					cert.getSerialNumber(), cert.getSubjectDN().getName(), caRevocationList));
+		}
+		return false;
+	}
+}
diff --git a/tests/com/gitblit/tests/X509UtilsTest.java b/tests/com/gitblit/tests/X509UtilsTest.java
new file mode 100644
index 0000000..85afce0
--- /dev/null
+++ b/tests/com/gitblit/tests/X509UtilsTest.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright 2012 gitblit.com.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.gitblit.tests;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
+
+import org.eclipse.jgit.util.FileUtils;
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+
+import com.gitblit.models.UserModel;
+import com.gitblit.utils.HttpUtils;
+import com.gitblit.utils.X509Utils;
+import com.gitblit.utils.X509Utils.RevocationReason;
+import com.gitblit.utils.X509Utils.X509Metadata;
+
+/**
+ * Unit tests for X509 certificate generation.
+ * 
+ * @author James Moger
+ * 
+ */
+public class X509UtilsTest extends Assert {
+	
+	// passwords are case-sensitive and may be length-limited
+	// based on the JCE policy files
+	String caPassword = "aBcDeFg";
+	File folder = new File(System.getProperty("user.dir"), "x509test");
+
+	@Before
+	public void prepare() throws Exception {
+		cleanUp();
+		X509Metadata goMetadata = new X509Metadata("localhost", caPassword);
+		X509Utils.prepareX509Infrastructure(goMetadata, folder);
+	}
+	
+	@After
+	public void cleanUp() throws Exception {
+		if (folder.exists()) {
+			FileUtils.delete(folder, FileUtils.RECURSIVE);
+		}
+	}
+	
+	@Test
+	public void testNewCA() throws Exception {		
+		File storeFile = new File(folder, X509Utils.CA_KEY_STORE);
+		X509Utils.getPrivateKey(X509Utils.CA_FN, storeFile, caPassword);
+		X509Certificate cert = X509Utils.getCertificate(X509Utils.CA_FN, storeFile, caPassword);
+		assertEquals("O=Gitblit,OU=Gitblit,CN=Gitblit Certificate Authority", cert.getIssuerDN().getName());
+	}	
+
+	@Test
+	public void testCertificateUserMapping() throws Exception {		
+		File storeFile = new File(folder, X509Utils.CA_KEY_STORE);
+		PrivateKey caPrivateKey = X509Utils.getPrivateKey(X509Utils.CA_FN, storeFile, caPassword);
+		X509Certificate caCert = X509Utils.getCertificate(X509Utils.CA_FN, storeFile, caPassword);
+		
+		X509Metadata userMetadata = new X509Metadata("james", "james");
+		userMetadata.serverHostname = "www.myserver.com";
+		userMetadata.userDisplayname = "James Moger";
+		userMetadata.passwordHint = "your name";
+		userMetadata.oids.put("C",  "US");
+		
+		X509Certificate cert1 = X509Utils.newClientCertificate(userMetadata, caPrivateKey, caCert, storeFile.getParentFile());
+		UserModel userModel1 = HttpUtils.getUserModelFromCertificate(cert1);
+		assertEquals(userMetadata.commonName, userModel1.username);
+		assertEquals(userMetadata.emailAddress, userModel1.emailAddress);
+		assertEquals("C=US,O=Gitblit,OU=Gitblit,CN=james", cert1.getSubjectDN().getName());
+		
+		
+		X509Certificate cert2 = X509Utils.newClientCertificate(userMetadata, caPrivateKey, caCert, storeFile.getParentFile());
+		UserModel userModel2 = HttpUtils.getUserModelFromCertificate(cert2);
+		assertEquals(userMetadata.commonName, userModel2.username);
+		assertEquals(userMetadata.emailAddress, userModel2.emailAddress);
+		assertEquals("C=US,O=Gitblit,OU=Gitblit,CN=james", cert2.getSubjectDN().getName());
+		
+		assertNotSame("Serial numbers are the same!", cert1.getSerialNumber().longValue(), cert2.getSerialNumber().longValue());
+	}
+	
+	@Test
+	public void testUserBundle() throws Exception {
+		File storeFile = new File(folder, X509Utils.CA_KEY_STORE);
+		
+		X509Metadata userMetadata = new X509Metadata("james", "james");
+		userMetadata.serverHostname = "www.myserver.com";		
+		userMetadata.userDisplayname = "James Moger";
+		userMetadata.passwordHint = "your name";
+
+		File zip = X509Utils.newClientBundle(userMetadata, storeFile, caPassword);
+		assertTrue(zip.exists());
+		
+		List<String> expected = Arrays.asList(userMetadata.commonName + ".pem", userMetadata.commonName + ".p12", "README.TXT");
+		
+		ZipInputStream zis = new ZipInputStream(new FileInputStream(zip));
+		ZipEntry entry = null;
+		while ((entry = zis.getNextEntry()) != null) {
+			assertTrue("Unexpected file: " + entry.getName(), expected.contains(entry.getName()));
+		}
+		zis.close();
+	}
+	
+	@Test
+	public void testCertificateRevocation() throws Exception {		
+		File storeFile = new File(folder, X509Utils.CA_KEY_STORE);
+		PrivateKey caPrivateKey = X509Utils.getPrivateKey(X509Utils.CA_FN, storeFile, caPassword);
+		X509Certificate caCert = X509Utils.getCertificate(X509Utils.CA_FN, storeFile, caPassword);
+		
+		X509Metadata userMetadata = new X509Metadata("james", "james");
+		userMetadata.serverHostname = "www.myserver.com";
+		userMetadata.userDisplayname = "James Moger";
+		userMetadata.passwordHint = "your name";
+		
+		// generate a new client certificate
+		X509Certificate cert1 = X509Utils.newClientCertificate(userMetadata, caPrivateKey, caCert, storeFile.getParentFile());
+		
+		// confirm this certificate IS NOT revoked
+		File caRevocationList = new File(folder, X509Utils.CA_REVOCATION_LIST);
+		assertFalse(X509Utils.isRevoked(cert1, caRevocationList));
+		
+		// revoke certificate and then confirm it IS revoked
+		X509Utils.revoke(cert1, RevocationReason.ACompromise, caRevocationList, storeFile, caPassword);
+		assertTrue(X509Utils.isRevoked(cert1, caRevocationList));
+		
+		// generate a second certificate
+		X509Certificate cert2 = X509Utils.newClientCertificate(userMetadata, caPrivateKey, caCert, storeFile.getParentFile());
+		
+		// confirm second certificate IS NOT revoked
+		assertTrue(X509Utils.isRevoked(cert1, caRevocationList));
+		assertFalse(X509Utils.isRevoked(cert2, caRevocationList));
+		
+		// revoke second certificate and then confirm it IS revoked
+		X509Utils.revoke(cert2, RevocationReason.ACompromise, caRevocationList, caPrivateKey);
+		assertTrue(X509Utils.isRevoked(cert1, caRevocationList));
+		assertTrue(X509Utils.isRevoked(cert2, caRevocationList));
+		
+		// generate a third certificate
+		X509Certificate cert3 = X509Utils.newClientCertificate(userMetadata, caPrivateKey, caCert, storeFile.getParentFile());
+		
+		// confirm third certificate IS NOT revoked
+		assertTrue(X509Utils.isRevoked(cert1, caRevocationList));
+		assertTrue(X509Utils.isRevoked(cert2, caRevocationList));
+		assertFalse(X509Utils.isRevoked(cert3, caRevocationList));
+		
+		// revoke third certificate and then confirm it IS revoked
+		X509Utils.revoke(cert3, RevocationReason.ACompromise, caRevocationList, caPrivateKey);
+		assertTrue(X509Utils.isRevoked(cert1, caRevocationList));
+		assertTrue(X509Utils.isRevoked(cert2, caRevocationList));
+		assertTrue(X509Utils.isRevoked(cert3, caRevocationList));
+	}
+}

--
Gitblit v1.9.1