/*
* Copyright (c) 2009-2012 David Grant
* Copyright (c) 2010 ThruPoint Ltd
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package org.jscep.client;
import java.io.IOException;
import java.math.BigInteger;
import java.net.URL;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.cert.CertStore;
import java.security.cert.CertStoreException;
import java.security.cert.X509CRL;
import java.security.cert.X509Certificate;
import java.util.Collection;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.CallbackHandler;
import javax.security.auth.callback.UnsupportedCallbackException;
import javax.security.auth.x500.X500Principal;
import org.apache.commons.codec.binary.Hex;
import org.bouncycastle.asn1.cms.IssuerAndSerialNumber;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.X509Extension;
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder;
import org.bouncycastle.operator.ContentVerifierProvider;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
import org.bouncycastle.pkcs.PKCS10CertificationRequest;
import org.jscep.asn1.IssuerAndSubject;
import org.jscep.client.verification.CertificateVerifier;
import org.jscep.message.PkcsPkiEnvelopeDecoder;
import org.jscep.message.PkcsPkiEnvelopeEncoder;
import org.jscep.message.PkiMessageDecoder;
import org.jscep.message.PkiMessageEncoder;
import org.jscep.transaction.EnrollmentTransaction;
import org.jscep.transaction.MessageType;
import org.jscep.transaction.NonEnrollmentTransaction;
import org.jscep.transaction.OperationFailureException;
import org.jscep.transaction.Transaction;
import org.jscep.transaction.Transaction.State;
import org.jscep.transaction.TransactionException;
import org.jscep.transaction.TransactionId;
import org.jscep.transport.HttpGetTransport;
import org.jscep.transport.HttpPostTransport;
import org.jscep.transport.Transport;
import org.jscep.transport.TransportException;
import org.jscep.transport.request.GetCaCapsRequest;
import org.jscep.transport.request.GetCaCertRequest;
import org.jscep.transport.request.GetNextCaCertRequest;
import org.jscep.transport.response.Capabilities;
import org.jscep.transport.response.GetCaCapsResponseHandler;
import org.jscep.transport.response.GetCaCertResponseHandler;
import org.jscep.transport.response.GetNextCaCertResponseHandler;
import org.jscep.util.X500Utils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The <tt>Client</tt> class is used for interacting with a SCEP server.
* <p>
* Typical usage might look like so:
*
* <pre>
* // Create the client
* URL server = new URL("http://jscep.org/scep/pkiclient.exe");
* CertificateVerifier verifier = new ConsoleCertificateVerifier();
* Client client = new Client(server, verifier);
*
* // Invoke operations on the client.
* client.getCaCapabilities();
* </pre>
*
* Each of the operations of this class is overloaded with a profile argument to
* support SCEP servers with multiple (or mandatory) profile names.
*/
public final class Client {
private static final Logger LOGGER = LoggerFactory.getLogger(Client.class);
// A requester MUST have the following information locally configured:
//
// 1. The Certification Authority IP address or fully qualified domain name
// 2. The Certification Authority HTTP CGI script path
//
// We use a URL for this.
private final URL url;
// A requester MUST have the following information locally configured:
//
// 3. The identifying information that is used for authentication of the
// Certification Authority in Section 4.1.1. This information MAY be
// obtained from the user, or presented to the end user for manual
// authorization during the protocol exchange (e.g. the user indicates
// acceptance of a fingerprint via a user-interface element).
//
// We use a callback handler for this.
private final CallbackHandler handler;
/**
* Constructs a new <tt>Client</tt> instance using the provided
* <tt>CallbackHandler</tt> for the provided URL.
* <p>
* The <tt>CallbackHandler</tt> must be able to handle
* {@link CertificateVerificationCallback}. Unless the
* <tt>CallbackHandler</tt> will be used to handle additional
* <tt>Callback</tt>s, users of this class are recommended to use the
* {@link #Client(URL, CertificateVerifier)} constructor instead.
*
* @param url
* the URL of the SCEP server.
* @param handler
* the callback handler used to check the CA identity.
*/
public Client(URL url, CallbackHandler handler) {
this.url = url;
this.handler = handler;
validateInput();
}
/**
* Constructs a new <tt>Client</tt> instance using the provided
* <tt>CertificateVerifier</tt> for the provided URL.
* <p/>
* The provided <tt>CertificateVerifier</tt> is used to verify that the
* identity of the SCEP server matches what the client expects.
*
* @param url
* the URL of the SCEP server.
* @param verifier
* the verifier used to check the CA identity.
*/
public Client(URL url, CertificateVerifier verifier) {
this.url = url;
this.handler = new DefaultCallbackHandler(verifier);
validateInput();
}
/**
* Validates all the input to this client.
*
* @throws NullPointerException
* if any member variables are null.
* @throws IllegalArgumentException
* if any member variables are invalid.
*/
private void validateInput() throws NullPointerException,
IllegalArgumentException {
// Check for null values first.
if (url == null) {
throw new NullPointerException("URL should not be null");
}
if (!url.getProtocol().matches("^https?$")) {
throw new IllegalArgumentException(
"URL protocol should be HTTP or HTTPS");
}
if (url.getRef() != null) {
throw new IllegalArgumentException(
"URL should contain no reference");
}
if (url.getQuery() != null) {
throw new IllegalArgumentException(
"URL should contain no query string");
}
if (handler == null) {
throw new NullPointerException(
"Callback handler should not be null");
}
}
// INFORMATIONAL REQUESTS
/**
* Retrieves the set of SCEP capabilities from the CA.
*
* @return the capabilities of the server.
*/
public Capabilities getCaCapabilities() {
// NON-TRANSACTIONAL
return getCaCapabilities(null);
}
/**
* Retrieves the capabilities of the SCEP server.
* <p>
* This method provides support for SCEP servers with multiple profiles.
*
* @param profile
* the SCEP server profile.
* @return the capabilities of the server.
*/
public Capabilities getCaCapabilities(final String profile) {
LOGGER.debug("Determining capabilities of SCEP server");
// NON-TRANSACTIONAL
final GetCaCapsRequest req = new GetCaCapsRequest(profile);
final Transport trans = new HttpGetTransport(url);
try {
return trans.sendRequest(req, new GetCaCapsResponseHandler());
} catch (TransportException e) {
LOGGER.warn("Transport problem when determining capabilities. Using empty capabilities.");
return new Capabilities();
}
}
/**
* Retrieves the certificates used by the SCEP server.
* <p>
* This method queries the server for the certificates it will use in a SCEP
* message exchange. If the SCEP server represents a single entity, only a
* single CA certificate will be returned. If the SCEP server supports
* multiple entities (for example, if it uses a separate entity for signing
* SCEP messages), additional RA certificates will also be returned.
*
* @return the certificate store.
* @throws ClientException
* if any client error occurs.
* @see CertStoreInspector
*/
public CertStore getCaCertificate() throws ClientException {
return getCaCertificate(null);
}
/**
* Retrieves the certificates used by the SCEP server.
* <p>
* This method queries the server for the certificates it will use in a SCEP
* message exchange. If the SCEP server represents a single entity, only a
* single CA certificate will be returned. If the SCEP server supports
* multiple entities (for example, if it uses a separate entity for signing
* SCEP messages), additional RA certificates will also be returned.
* <p>
* This method provides support for SCEP servers with multiple profiles.
*
* @param profile
* the SCEP server profile.
* @return the certificate store.
* @throws ClientException
* if any client error occurs.
* @see CertStoreInspector
*/
public CertStore getCaCertificate(final String profile)
throws ClientException {
LOGGER.debug("Retrieving current CA certificate");
// NON-TRANSACTIONAL
// CA and RA public key distribution
final GetCaCertRequest req = new GetCaCertRequest(profile);
final Transport trans = new HttpGetTransport(url);
CertStore store;
try {
store = trans.sendRequest(req, new GetCaCertResponseHandler());
} catch (TransportException e) {
throw new ClientException(e);
}
CertStoreInspector certs = CertStoreInspector.getInstance(store);
verifyCA(certs.getIssuer());
verifyRA(certs.getIssuer(), certs.getRecipient());
verifyRA(certs.getIssuer(), certs.getSigner());
return store;
}
private void verifyRA(X509Certificate ca, X509Certificate ra)
throws ClientException {
LOGGER.debug("Verifying signature of RA certificate");
if (ca.equals(ra)) {
LOGGER.debug("RA and CA are identical");
return;
}
try {
JcaX509CertificateHolder raHolder = new JcaX509CertificateHolder(ra);
ContentVerifierProvider verifierProvider = new JcaContentVerifierProviderBuilder()
.build(ca);
if (!raHolder.isSignatureValid(verifierProvider)) {
LOGGER.debug("Signature verification failed for RA.");
throw new ClientException("RA not issued by CA");
} else {
LOGGER.debug("Signature verification passed for RA.");
}
} catch (Exception e) {
throw new ClientException(e);
}
}
/**
* Retrieves the next certificate to be used by the CA.
* <p>
* This method will query the SCEP server to determine if the CA is
* scheduled to start using a new certificate for issuing.
*
* @return the certificate store.
* @throws ClientException
* if any client error occurs.
* @see CertStoreInspector
*/
public CertStore getRolloverCertificate() throws ClientException {
return getRolloverCertificate(null);
}
/**
* Retrieves the next certificate to be used by the CA.
* <p>
* This method will query the SCEP server to determine if the CA is
* scheduled to start using a new certificate for issuing.
* <p>
* This method provides support for SCEP servers with multiple profiles.
*
* @param profile
* the SCEP server profile.
* @return the certificate store.
* @throws ClientException
* if any client error occurs.
* @see CertStoreInspector
*/
public CertStore getRolloverCertificate(final String profile)
throws ClientException {
LOGGER.debug("Retriving next CA certificate from CA");
// NON-TRANSACTIONAL
if (!getCaCapabilities(profile).isRolloverSupported()) {
throw new UnsupportedOperationException();
}
final CertStore store = getCaCertificate(profile);
// The CA or RA
CertStoreInspector certs = CertStoreInspector.getInstance(store);
final X509Certificate signer = certs.getSigner();
final Transport trans = new HttpGetTransport(url);
final GetNextCaCertRequest req = new GetNextCaCertRequest(profile);
try {
return trans.sendRequest(req, new GetNextCaCertResponseHandler(
signer));
} catch (TransportException e) {
throw new ClientException(e);
}
}
// TRANSACTIONAL
/**
* Returns the certificate revocation list a given issuer and serial number.
* <p>
* This method requests a CRL for a certificate as identified by the issuer
* name and the certificate serial number.
*
* @param identity
* the identity of the client.
* @param key
* the private key to sign the SCEP request.
* @param issuer
* the name of the certificate issuer.
* @param serial
* the serial number of the certificate.
* @return the CRL corresponding to the issuer and serial.
* @throws ClientException
* if any client errors occurs.
* @throws OperationFailureException
* if the request fails.
*/
public X509CRL getRevocationList(X509Certificate identity, PrivateKey key,
final X500Principal issuer, final BigInteger serial)
throws ClientException, OperationFailureException {
return getRevocationList(identity, key, issuer, serial, null);
}
/**
* Returns the certificate revocation list a given issuer and serial number.
* <p>
* This method requests a CRL for a certificate as identified by the issuer
* name and the certificate serial number.
* <p>
* This method provides support for SCEP servers with multiple profiles.
*
* @param identity
* the identity of the client.
* @param key
* the private key to sign the SCEP request.
* @param issuer
* the name of the certificate issuer.
* @param serial
* the serial number of the certificate.
* @param profile
* the SCEP server profile.
* @return the CRL corresponding to the issuer and serial.
* @throws ClientException
* if any client errors occurs.
* @throws OperationFailureException
* if the request fails.
*/
@SuppressWarnings("unchecked")
public X509CRL getRevocationList(X509Certificate identity, PrivateKey key,
final X500Principal issuer, final BigInteger serial,
final String profile) throws ClientException,
OperationFailureException {
LOGGER.debug("Retriving CRL from CA");
// TRANSACTIONAL
// CRL query
checkDistributionPoints(profile);
X500Name name = new X500Name(issuer.getName());
IssuerAndSerialNumber iasn = new IssuerAndSerialNumber(name, serial);
Transport transport = createTransport(profile);
final Transaction t = new NonEnrollmentTransaction(transport,
getEncoder(identity, key, profile), getDecoder(identity, key,
profile), iasn, MessageType.GET_CRL);
State state;
try {
state = t.send();
} catch (TransactionException e) {
throw new ClientException(e);
}
if (state == State.CERT_ISSUED) {
try {
Collection<X509CRL> crls = (Collection<X509CRL>) t
.getCertStore().getCRLs(null);
if (crls.size() == 0) {
return null;
}
return crls.iterator().next();
} catch (CertStoreException e) {
throw new RuntimeException(e);
}
} else if (state == State.CERT_REQ_PENDING) {
throw new IllegalStateException();
} else {
throw new OperationFailureException(t.getFailInfo());
}
}
private void checkDistributionPoints(final String profile)
throws ClientException {
CertStore store = getCaCertificate(profile);
CertStoreInspector certs = CertStoreInspector.getInstance(store);
final X509Certificate ca = certs.getIssuer();
if (ca.getExtensionValue(X509Extension.cRLDistributionPoints.getId()) != null) {
LOGGER.warn("CA supports distribution points");
}
}
/**
* Retrieves the certificate corresponding to the provided serial number.
* <p>
* This request relates only to the current CA certificate. If the CA
* certificate has changed since the requested certificate was issued, this
* operation will fail.
*
* @param identity
* the identity of the client.
* @param key
* the private key to sign the SCEP request.
* @param serial
* the serial number of the requested certificate.
* @return the certificate store containing the requested certificate.
* @throws ClientException
* if any client error occurs.
* @throws OperationFailureException
* if the SCEP server refuses to service the request.
*/
public CertStore getCertificate(X509Certificate identity, PrivateKey key,
BigInteger serial) throws ClientException,
OperationFailureException {
return getCertificate(identity, key, serial, null);
}
/**
* Retrieves the certificate corresponding to the provided serial number.
* <p>
* This request relates only to the current CA certificate. If the CA
* certificate has changed since the requested certificate was issued, this
* operation will fail.
* <p>
* This method provides support for SCEP servers with multiple profiles.
*
* @param identity
* the identity of the client.
* @param key
* the private key to sign the SCEP request.
* @param serial
* the serial number of the requested certificate.
* @param profile
* the SCEP server profile.
* @return the certificate store containing the requested certificate.
* @throws ClientException
* if any client error occurs.
* @throws OperationFailureException
* if the SCEP server refuses to service the request.
*/
public CertStore getCertificate(X509Certificate identity, PrivateKey key,
BigInteger serial, String profile)
throws OperationFailureException, ClientException {
LOGGER.debug("Retriving certificate from CA");
// TRANSACTIONAL
// Certificate query
final CertStore store = getCaCertificate(profile);
CertStoreInspector certs = CertStoreInspector.getInstance(store);
final X509Certificate ca = certs.getIssuer();
X500Name name = new X500Name(ca.getIssuerX500Principal().toString());
IssuerAndSerialNumber iasn = new IssuerAndSerialNumber(name, serial);
Transport transport = createTransport(profile);
final Transaction t = new NonEnrollmentTransaction(transport,
getEncoder(identity, key, profile), getDecoder(identity, key,
profile), iasn, MessageType.GET_CERT);
State state;
try {
state = t.send();
} catch (TransactionException e) {
throw new ClientException(e);
}
if (state == State.CERT_ISSUED) {
return t.getCertStore();
} else if (state == State.CERT_REQ_PENDING) {
throw new IllegalStateException();
} else {
throw new OperationFailureException(t.getFailInfo());
}
}
/**
* Sends a CSR to the SCEP server for enrolling in a PKI.
* <p>
* This method enrols the provider <tt>CertificationRequest</tt> into the
* PKI represented by the SCEP server.
*
* @param identity
* the identity of the client.
* @param key
* the private key to sign the SCEP request.
* @param csr
* the CSR to enrol.
* @return the certificate store returned by the server.
* @throws ClientException
* if any client error occurs.
* @throws OperationFailureException
* if the operation fails.
* @throws PollingTerminatedException
* if polling is terminated
* @throws TransactionException
* if there is a problem with the SCEP transaction.
* @see CertStoreInspector
*/
public EnrollmentResponse enrol(X509Certificate identity, PrivateKey key,
final PKCS10CertificationRequest csr) throws ClientException,
TransactionException {
return enrol(identity, key, csr, null);
}
/**
* Sends a CSR to the SCEP server for enrolling in a PKI.
* <p>
* This method enrols the provider <tt>CertificationRequest</tt> into the
* PKI represented by the SCEP server.
*
* @param identity
* the identity of the client.
* @param key
* the private key to sign the SCEP request.
* @param csr
* the CSR to enrol.
* @param profile
* the SCEP server profile.
* @return the certificate store returned by the server.
* @throws ClientException
* if any client error occurs.
* @throws OperationFailureException
* if the operation fails.
* @throws PollingTerminatedException
* if polling is terminated
* @throws TransactionException
* if there is a problem with the SCEP transaction.
* @see CertStoreInspector
*/
public EnrollmentResponse enrol(X509Certificate identity, PrivateKey key,
final PKCS10CertificationRequest csr, String profile)
throws ClientException, TransactionException {
LOGGER.debug("Enrolling certificate with CA");
if (isSelfSigned(identity)) {
LOGGER.debug("Certificate is self-signed");
X500Name csrSubject = csr.getSubject();
X500Name idSubject = X500Utils.toX500Name(identity
.getSubjectX500Principal());
if (!csrSubject.equals(idSubject)) {
LOGGER.error("The self-signed certificate MUST use the same subject name as in the PKCS#10 request.");
}
}
// TRANSACTIONAL
// Certificate enrollment
final Transport transport = createTransport(profile);
PkiMessageEncoder encoder = getEncoder(identity, key, profile);
PkiMessageDecoder decoder = getDecoder(identity, key, profile);
final EnrollmentTransaction trans = new EnrollmentTransaction(
transport, encoder, decoder, csr);
try {
MessageDigest digest = getCaCapabilities(profile)
.getStrongestMessageDigest();
byte[] hash = digest.digest(csr.getEncoded());
LOGGER.info("{} PKCS#10 Fingerprint: [{}]", digest.getAlgorithm(),
Hex.encodeHexString(hash));
} catch (IOException e) {
LOGGER.error("Error getting encoded CSR", e);
}
return send(trans);
}
private boolean isSelfSigned(X509Certificate cert) throws ClientException {
try {
JcaX509CertificateHolder holder = new JcaX509CertificateHolder(cert);
ContentVerifierProvider verifierProvider = new JcaContentVerifierProviderBuilder()
.build(holder);
return holder.isSignatureValid(verifierProvider);
} catch (Exception e) {
throw new ClientException(e);
}
}
public EnrollmentResponse poll(X509Certificate identity,
PrivateKey identityKey, X500Principal subject, TransactionId transId)
throws ClientException, TransactionException {
return poll(identity, identityKey, subject, transId, null);
}
public EnrollmentResponse poll(X509Certificate identity,
PrivateKey identityKey, X500Principal subject,
TransactionId transId, String profile) throws ClientException,
TransactionException {
final Transport transport = createTransport(profile);
CertStore store = getCaCertificate(profile);
CertStoreInspector certStore = CertStoreInspector.getInstance(store);
X509Certificate issuer = certStore.getIssuer();
PkiMessageEncoder encoder = getEncoder(identity, identityKey, profile);
PkiMessageDecoder decoder = getDecoder(identity, identityKey, profile);
IssuerAndSubject ias = new IssuerAndSubject(X500Utils.toX500Name(issuer
.getIssuerX500Principal()), X500Utils.toX500Name(subject));
final EnrollmentTransaction trans = new EnrollmentTransaction(
transport, encoder, decoder, ias, transId);
return send(trans);
}
private EnrollmentResponse send(final EnrollmentTransaction trans)
throws TransactionException {
State s = trans.send();
if (s == State.CERT_ISSUED) {
return new EnrollmentResponse(trans.getId(), trans.getCertStore());
} else if (s == State.CERT_REQ_PENDING) {
return new EnrollmentResponse(trans.getId());
} else {
return new EnrollmentResponse(trans.getId(), trans.getFailInfo());
}
}
private PkiMessageEncoder getEncoder(X509Certificate identity,
PrivateKey priKey, String profile) throws ClientException {
CertStore store = getCaCertificate(profile);
Capabilities caps = getCaCapabilities(profile);
CertStoreInspector certs = CertStoreInspector.getInstance(store);
X509Certificate recipientCertificate = certs.getRecipient();
PkcsPkiEnvelopeEncoder envEncoder = new PkcsPkiEnvelopeEncoder(
recipientCertificate, caps.getStrongestCipher());
String sigAlg = caps.getStrongestSignatureAlgorithm();
return new PkiMessageEncoder(priKey, identity, envEncoder, sigAlg);
}
private PkiMessageDecoder getDecoder(X509Certificate identity,
PrivateKey key, String profile) throws ClientException {
final CertStore store = getCaCertificate(profile);
CertStoreInspector certs = CertStoreInspector.getInstance(store);
X509Certificate signer = certs.getSigner();
PkcsPkiEnvelopeDecoder envDecoder = new PkcsPkiEnvelopeDecoder(
identity, key);
return new PkiMessageDecoder(signer, envDecoder);
}
/**
* Creates a new transport based on the capabilities of the server.
*
* @param profile
* profile to use for determining if HTTP POST is supported
* @return the new transport.
* @throws IOException
* if any I/O error occurs.
*/
private Transport createTransport(final String profile) {
if (getCaCapabilities(profile).isPostSupported()) {
return new HttpPostTransport(url);
} else {
return new HttpGetTransport(url);
}
}
private void verifyCA(X509Certificate cert) throws ClientException {
CertificateVerificationCallback callback = new CertificateVerificationCallback(
cert);
try {
LOGGER.debug("Requesting certificate verification.");
Callback[] callbacks = new Callback[] { callback };
handler.handle(callbacks);
} catch (UnsupportedCallbackException e) {
LOGGER.debug("Certificate verification failed.");
throw new ClientException(e);
} catch (IOException e) {
throw new ClientException(e);
}
if (!callback.isVerified()) {
LOGGER.debug("Certificate verification failed.");
throw new ClientException(
"CA certificate fingerprint could not be verified.");
} else {
LOGGER.debug("Certificate verification passed.");
}
}
}