From b423580c552ff2c2d47829b0e1632a77958fec68 Mon Sep 17 00:00:00 2001 From: James Moger 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/FileUtils.java | 19 + src/com/gitblit/utils/X509Utils.java | 991 +++++++++++++++++++++ tests/com/gitblit/tests/X509UtilsTest.java | 172 ++++ 3 files changed, 1182 insertions(+) create mode 100644 src/com/gitblit/utils/X509Utils.java create mode 100644 tests/com/gitblit/tests/X509UtilsTest.java diff --git a/src/com/gitblit/utils/FileUtils.java b/src/com/gitblit/utils/FileUtils.java index cba88d0c..0b8aeb4a 100644 --- a/src/com/gitblit/utils/FileUtils.java +++ b/src/com/gitblit/utils/FileUtils.java @@ -99,6 +99,25 @@ public class FileUtils { } 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 00000000..e6ffec1a --- /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 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(); + } + + 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 certs = new HashSet(); + 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 trustAnchors = new HashSet(); + 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 00000000..85afce0c --- /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 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)); + } +} -- 2.39.5