blob: 0dca634a6cf23b04ac983947bd71fed7a1a59c2c [file] [log] [blame]
package org.bouncycastle.est;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.URL;
import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.TimeZone;
import java.util.regex.Pattern;
import org.bouncycastle.asn1.ASN1InputStream;
import org.bouncycastle.asn1.ASN1Sequence;
import org.bouncycastle.asn1.DERPrintableString;
import org.bouncycastle.asn1.cms.ContentInfo;
import org.bouncycastle.asn1.est.CsrAttrs;
import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers;
import org.bouncycastle.cert.X509CRLHolder;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cmc.CMCException;
import org.bouncycastle.cmc.SimplePKIResponse;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder;
import org.bouncycastle.util.Selector;
import org.bouncycastle.util.Store;
import org.bouncycastle.util.encoders.Base64;
/**
* ESTService provides unified access to an EST server which is defined as implementing
* RFC7030.
*/
public class ESTService
{
protected static final String CACERTS = "/cacerts";
protected static final String SIMPLE_ENROLL = "/simpleenroll";
protected static final String SIMPLE_REENROLL = "/simplereenroll";
protected static final String FULLCMC = "/fullcmc";
protected static final String SERVERGEN = "/serverkeygen";
protected static final String CSRATTRS = "/csrattrs";
protected static final Set<String> illegalParts = new HashSet<String>();
static
{
illegalParts.add(CACERTS.substring(1));
illegalParts.add(SIMPLE_ENROLL.substring(1));
illegalParts.add(SIMPLE_REENROLL.substring(1));
illegalParts.add(FULLCMC.substring(1));
illegalParts.add(SERVERGEN.substring(1));
illegalParts.add(CSRATTRS.substring(1));
}
private final String server;
private final ESTClientProvider clientProvider;
private static final Pattern pathInvalid = Pattern.compile("^[0-9a-zA-Z_\\-.~!$&'()*+,;=]+");
ESTService(
String serverAuthority, String label,
ESTClientProvider clientProvider)
{
serverAuthority = verifyServer(serverAuthority);
if (label != null)
{
label = verifyLabel(label);
server = "https://" + serverAuthority + "/.well-known/est/" + label;
}
else
{
server = "https://" + serverAuthority + "/.well-known/est";
}
this.clientProvider = clientProvider;
}
/**
* Utility method to extract all the X509Certificates from a store and return them in an array.
*
* @param store The store.
* @return An arrar of certificates/
*/
public static X509CertificateHolder[] storeToArray(Store<X509CertificateHolder> store)
{
return storeToArray(store, null);
}
/**
* Utility method to extract all the X509Certificates from a store using a filter and to return them
* as an array.
*
* @param store The store.
* @param selector The selector.
* @return An array of X509Certificates.
*/
public static X509CertificateHolder[] storeToArray(Store<X509CertificateHolder> store, Selector<X509CertificateHolder> selector)
{
Collection<X509CertificateHolder> c = store.getMatches(selector);
return c.toArray(new X509CertificateHolder[c.size()]);
}
/**
* Query the EST server for ca certificates.
* <p>
* RFC7030 leans heavily on the verification phases of TLS for both client and server verification.
* <p>
* It does however define a bootstrapping mode where if the client does not have the necessary ca certificates to
* validate the server it can defer to an external source, such as a human, to formally accept the ca certs.
* <p>
* If callers are using bootstrapping they must examine the CACertsResponse and validate it externally.
*
* @return A store of X509Certificates.
*/
public CACertsResponse getCACerts()
throws Exception
{
ESTResponse resp = null;
Exception finalThrowable = null;
CACertsResponse caCertsResponse = null;
URL url = null;
boolean failedBeforeClose = false;
try
{
url = new URL(server + CACERTS);
ESTClient client = clientProvider.makeClient();
ESTRequest req = new ESTRequestBuilder("GET", url).withClient(client).build();
resp = client.doRequest(req);
Store<X509CertificateHolder> caCerts = null;
Store<X509CRLHolder> crlHolderStore = null;
if (resp.getStatusCode() == 200)
{
if (!"application/pkcs7-mime".equals(resp.getHeaders().getFirstValue("Content-Type")))
{
String j = resp.getHeaders().getFirstValue("Content-Type") != null ? " got " + resp.getHeaders().getFirstValue("Content-Type") : " but was not present.";
throw new ESTException(("Response : " + url.toString() + "Expecting application/pkcs7-mime ") + j, null, resp.getStatusCode(), resp.getInputStream());
}
try
{
if (resp.getContentLength() != null && resp.getContentLength() > 0)
{
ASN1InputStream ain = new ASN1InputStream(resp.getInputStream());
SimplePKIResponse spkr = new SimplePKIResponse(ContentInfo.getInstance((ASN1Sequence)ain.readObject()));
caCerts = spkr.getCertificates();
crlHolderStore = spkr.getCRLs();
}
}
catch (Throwable ex)
{
throw new ESTException("Decoding CACerts: " + url.toString() + " " + ex.getMessage(), ex, resp.getStatusCode(), resp.getInputStream());
}
}
else if (resp.getStatusCode() != 204) // 204 are No Content
{
throw new ESTException("Get CACerts: " + url.toString(), null, resp.getStatusCode(), resp.getInputStream());
}
caCertsResponse = new CACertsResponse(caCerts, crlHolderStore, req, resp.getSource(), clientProvider.isTrusted());
}
catch (Throwable t)
{
failedBeforeClose = true;
if (t instanceof ESTException)
{
throw (ESTException)t;
}
else
{
throw new ESTException(t.getMessage(), t);
}
}
finally
{
if (resp != null)
{
try
{
resp.close();
}
catch (Exception t)
{
finalThrowable = t;
}
}
}
if (finalThrowable != null)
{
if (finalThrowable instanceof ESTException)
{
throw finalThrowable;
}
throw new ESTException("Get CACerts: " + url.toString(), finalThrowable, resp.getStatusCode(), null);
}
return caCertsResponse;
}
/**
* Reissue an existing request where the server had previously returned a 202.
*
* @param priorResponse The prior response.
* @return A new ESTEnrollmentResponse
* @throws Exception
*/
public EnrollmentResponse simpleEnroll(EnrollmentResponse priorResponse)
throws Exception
{
if (!clientProvider.isTrusted())
{
throw new IllegalStateException("No trust anchors.");
}
ESTResponse resp = null;
try
{
ESTClient client = clientProvider.makeClient();
resp = client.doRequest(new ESTRequestBuilder(priorResponse.getRequestToRetry()).withClient(client).build());
return handleEnrollResponse(resp);
}
catch (Throwable t)
{
if (t instanceof ESTException)
{
throw (ESTException)t;
}
else
{
throw new ESTException(t.getMessage(), t);
}
}
finally
{
if (resp != null)
{
resp.close();
}
}
}
/**
* Perform a simple enrollment operation.
* <p>
* This method accepts an ESPHttpAuth instance to provide basic or digest authentication.
* <p>
* If authentication is to be performed as part of TLS then this instances client keystore and their keystore
* password need to be specified.
*
* @param certificationRequest The certification request.
* @param auth The http auth provider, basic auth or digest auth, can be null.
* @return The enrolled certificate.
*/
public EnrollmentResponse simpleEnroll(boolean reenroll, PKCS10CertificationRequest certificationRequest, ESTAuth auth)
throws IOException
{
if (!clientProvider.isTrusted())
{
throw new IllegalStateException("No trust anchors.");
}
ESTResponse resp = null;
try
{
final byte[] data = annotateRequest(certificationRequest.getEncoded()).getBytes();
URL url = new URL(server + (reenroll ? SIMPLE_REENROLL : SIMPLE_ENROLL));
ESTClient client = clientProvider.makeClient();
ESTRequestBuilder req = new ESTRequestBuilder("POST", url).withData(data).withClient(client);
req.addHeader("Content-Type", "application/pkcs10");
req.addHeader("Content-Length", "" + data.length);
req.addHeader("Content-Transfer-Encoding", "base64");
if (auth != null)
{
auth.applyAuth(req);
}
resp = client.doRequest(req.build());
return handleEnrollResponse(resp);
}
catch (Throwable t)
{
if (t instanceof ESTException)
{
throw (ESTException)t;
}
else
{
throw new ESTException(t.getMessage(), t);
}
}
finally
{
if (resp != null)
{
resp.close();
}
}
}
/**
* Implements Enroll with PoP.
* Request will have the tls-unique attribute added to it before it is signed and completed.
*
* @param reEnroll True = re enroll.
* @param builder The request builder.
* @param contentSigner The content signer.
* @param auth Auth modes.
* @return Enrollment response.
* @throws IOException
*/
public EnrollmentResponse simpleEnrollPoP(boolean reEnroll, final PKCS10CertificationRequestBuilder builder, final ContentSigner contentSigner, ESTAuth auth)
throws IOException
{
if (!clientProvider.isTrusted())
{
throw new IllegalStateException("No trust anchors.");
}
ESTResponse resp = null;
try
{
URL url = new URL(server + (reEnroll ? SIMPLE_REENROLL : SIMPLE_ENROLL));
ESTClient client = clientProvider.makeClient();
//
// Connect supplying a source listener.
// The source listener is responsible for completing the PCS10 Cert request and encoding it.
//
ESTRequestBuilder reqBldr = new ESTRequestBuilder("POST", url).withClient(client).withConnectionListener(new ESTSourceConnectionListener()
{
public ESTRequest onConnection(Source source, ESTRequest request)
throws IOException
{
//
// Add challenge password from tls unique
//
if (source instanceof TLSUniqueProvider && ((TLSUniqueProvider)source).isTLSUniqueAvailable())
{
PKCS10CertificationRequestBuilder localBuilder = new PKCS10CertificationRequestBuilder(builder);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] tlsUnique = ((TLSUniqueProvider)source).getTLSUnique();
localBuilder.setAttribute(PKCSObjectIdentifiers.pkcs_9_at_challengePassword, new DERPrintableString(Base64.toBase64String(tlsUnique)));
bos.write(annotateRequest(localBuilder.build(contentSigner).getEncoded()).getBytes());
bos.flush();
ESTRequestBuilder reqBuilder = new ESTRequestBuilder(request).withData(bos.toByteArray());
reqBuilder.setHeader("Content-Type", "application/pkcs10");
reqBuilder.setHeader("Content-Transfer-Encoding", "base64");
reqBuilder.setHeader("Content-Length", Long.toString(bos.size()));
return reqBuilder.build();
}
else
{
throw new IOException("Source does not supply TLS unique.");
}
}
});
if (auth != null)
{
auth.applyAuth(reqBldr);
}
resp = client.doRequest(reqBldr.build());
return handleEnrollResponse(resp);
}
catch (Throwable t)
{
if (t instanceof ESTException)
{
throw (ESTException)t;
}
else
{
throw new ESTException(t.getMessage(), t);
}
}
finally
{
if (resp != null)
{
resp.close();
}
}
}
/**
* Handles the enroll response, deals with status codes and setting of delays.
*
* @param resp The response.
* @return An EnrollmentResponse.
* @throws IOException
*/
protected EnrollmentResponse handleEnrollResponse(ESTResponse resp)
throws IOException
{
ESTRequest req = resp.getOriginalRequest();
Store<X509CertificateHolder> enrolled = null;
if (resp.getStatusCode() == 202)
{
// Received but not ready.
String rt = resp.getHeader("Retry-After");
if (rt == null)
{
throw new ESTException("Got Status 202 but not Retry-After header from: " + req.getURL().toString());
}
long notBefore = -1;
try
{
notBefore = System.currentTimeMillis() + (Long.parseLong(rt) * 1000);
}
catch (NumberFormatException nfe)
{
try
{
SimpleDateFormat dateFormat = new SimpleDateFormat(
"EEE, dd MMM yyyy HH:mm:ss z", Locale.US);
dateFormat.setTimeZone(TimeZone.getTimeZone("GMT"));
notBefore = dateFormat.parse(rt).getTime();
}
catch (Exception ex)
{
throw new ESTException(
"Unable to parse Retry-After header:" + req.getURL().toString() + " " + ex.getMessage(), null,
resp.getStatusCode(), resp.getInputStream());
}
}
return new EnrollmentResponse(null, notBefore, req, resp.getSource());
}
else if (resp.getStatusCode() == 200)
{
ASN1InputStream ain = new ASN1InputStream(resp.getInputStream());
SimplePKIResponse spkr = null;
try
{
spkr = new SimplePKIResponse(ContentInfo.getInstance(ain.readObject()));
}
catch (CMCException e)
{
throw new ESTException(e.getMessage(), e.getCause());
}
enrolled = spkr.getCertificates();
return new EnrollmentResponse(enrolled, -1, null, resp.getSource());
}
throw new ESTException(
"Simple Enroll: " + req.getURL().toString(), null,
resp.getStatusCode(), resp.getInputStream());
}
/**
* Fetch he CSR Attributes from the server.
*
* @return A CSRRequestResponse with the attributes.
* @throws ESTException
*/
public CSRRequestResponse getCSRAttributes()
throws ESTException
{
if (!clientProvider.isTrusted())
{
throw new IllegalStateException("No trust anchors.");
}
ESTResponse resp = null;
CSRAttributesResponse response = null;
Exception finalThrowable = null;
URL url = null;
try
{
url = new URL(server + CSRATTRS);
ESTClient client = clientProvider.makeClient();
ESTRequest req = new ESTRequestBuilder("GET", url).withClient(client).build(); // new ESTRequest("GET", url, null);
resp = client.doRequest(req);
switch (resp.getStatusCode())
{
case 200:
try
{
if (resp.getContentLength() != null && resp.getContentLength() > 0)
{
ASN1InputStream ain = new ASN1InputStream(resp.getInputStream());
ASN1Sequence seq = (ASN1Sequence)ain.readObject();
response = new CSRAttributesResponse(CsrAttrs.getInstance(seq));
}
}
catch (Throwable ex)
{
throw new ESTException("Decoding CACerts: " + url.toString() + " " + ex.getMessage(), ex, resp.getStatusCode(), resp.getInputStream());
}
break;
case 204:
response = null;
break;
case 404:
response = null;
break;
default:
throw new ESTException(
"CSR Attribute request: " + req.getURL().toString(), null,
resp.getStatusCode(), resp.getInputStream());
}
}
catch (Throwable t)
{
if (t instanceof ESTException)
{
throw (ESTException)t;
}
else
{
throw new ESTException(t.getMessage(), t);
}
}
finally
{
if (resp != null)
{
try
{
resp.close();
}
catch (Exception ex)
{
finalThrowable = ex;
}
}
}
if (finalThrowable != null)
{
if (finalThrowable instanceof ESTException)
{
throw (ESTException)finalThrowable;
}
throw new ESTException(finalThrowable.getMessage(), finalThrowable, resp.getStatusCode(), null);
}
return new CSRRequestResponse(response, resp.getSource());
}
private String annotateRequest(byte[] data)
{
int i = 0;
StringWriter sw = new StringWriter();
PrintWriter pw = new PrintWriter(sw);
// pw.print("-----BEGIN CERTIFICATE REQUEST-----\n");
do
{
if (i + 48 < data.length)
{
pw.print(Base64.toBase64String(data, i, 48));
i += 48;
}
else
{
pw.print(Base64.toBase64String(data, i, data.length - i));
i = data.length;
}
pw.print('\n');
}
while (i < data.length);
// pw.print("-----END CERTIFICATE REQUEST-----\n");
pw.flush();
return sw.toString();
}
private String verifyLabel(String label)
{
while (label.endsWith("/") && label.length() > 0)
{
label = label.substring(0, label.length() - 1);
}
while (label.startsWith("/") && label.length() > 0)
{
label = label.substring(1);
}
if (label.length() == 0)
{
throw new IllegalArgumentException("Label set but after trimming '/' is not zero length string.");
}
if (!pathInvalid.matcher(label).matches())
{
throw new IllegalArgumentException("Server path " + label + " contains invalid characters");
}
if (illegalParts.contains(label))
{
throw new IllegalArgumentException("Label " + label + " is a reserved path segment.");
}
return label;
}
private String verifyServer(String server)
{
try
{
while (server.endsWith("/") && server.length() > 0)
{
server = server.substring(0, server.length() - 1);
}
if (server.contains("://"))
{
throw new IllegalArgumentException("Server contains scheme, must only be <dnsname/ipaddress>:port, https:// will be added arbitrarily.");
}
URL u = new URL("https://" + server);
if (u.getPath().length() == 0 || u.getPath().equals("/"))
{
return server;
}
throw new IllegalArgumentException("Server contains path, must only be <dnsname/ipaddress>:port, a path of '/.well-known/est/<label>' will be added arbitrarily.");
}
catch (Exception ex)
{
if (ex instanceof IllegalArgumentException)
{
throw (IllegalArgumentException)ex;
}
throw new IllegalArgumentException("Scheme and host is invalid: " + ex.getMessage(), ex);
}
}
}