001/*
002 * acme4j - Java ACME client
003 *
004 * Copyright (C) 2016 Richard "Shred" K├Ârber
005 *   http://acme4j.shredzone.org
006 *
007 * Licensed under the Apache License, Version 2.0 (the "License");
008 * you may not use this file except in compliance with the License.
009 *
010 * This program is distributed in the hope that it will be useful,
011 * but WITHOUT ANY WARRANTY; without even the implied warranty of
012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
013 */
014package org.shredzone.acme4j;
015
016import static java.util.Collections.unmodifiableList;
017
018import java.io.IOException;
019import java.io.Writer;
020import java.net.URL;
021import java.security.KeyPair;
022import java.security.cert.CertificateEncodingException;
023import java.security.cert.X509Certificate;
024import java.util.ArrayList;
025import java.util.Collections;
026import java.util.List;
027
028import javax.annotation.Nullable;
029import javax.annotation.ParametersAreNonnullByDefault;
030import javax.annotation.WillNotClose;
031
032import org.shredzone.acme4j.connector.Connection;
033import org.shredzone.acme4j.connector.Resource;
034import org.shredzone.acme4j.exception.AcmeException;
035import org.shredzone.acme4j.exception.AcmeLazyLoadingException;
036import org.shredzone.acme4j.exception.AcmeProtocolException;
037import org.shredzone.acme4j.toolbox.AcmeUtils;
038import org.shredzone.acme4j.toolbox.JSONBuilder;
039import org.slf4j.Logger;
040import org.slf4j.LoggerFactory;
041
042/**
043 * Represents a certificate and its certificate chain.
044 * <p>
045 * Note that a certificate is immutable once it is issued. For renewal, a new certificate
046 * must be ordered.
047 */
048@ParametersAreNonnullByDefault
049public class Certificate extends AcmeResource {
050    private static final long serialVersionUID = 7381527770159084201L;
051    private static final Logger LOG = LoggerFactory.getLogger(Certificate.class);
052
053    private ArrayList<X509Certificate> certChain = null;
054    private ArrayList<URL> alternates = null;
055
056    protected Certificate(Login login, URL certUrl) {
057        super(login, certUrl);
058    }
059
060    /**
061     * Downloads the certificate chain.
062     * <p>
063     * The certificate is downloaded lazily by the other methods. So usually there is no
064     * need to invoke this method, unless the download is to be enforced. If the
065     * certificate has been downloaded already, nothing will happen.
066     *
067     * @throws AcmeException
068     *             if the certificate could not be downloaded
069     */
070    public void download() throws AcmeException {
071        if (certChain == null) {
072            LOG.debug("download");
073            try (Connection conn = getSession().connect()) {
074                conn.sendCertificateRequest(getLocation(), getLogin());
075                alternates = new ArrayList<>(conn.getLinks("alternate"));
076                certChain = new ArrayList<>(conn.readCertificates());
077            }
078        }
079    }
080
081    /**
082     * Returns the created certificate.
083     *
084     * @return The created end-entity {@link X509Certificate} without issuer chain.
085     */
086    public X509Certificate getCertificate() {
087        lazyDownload();
088        return certChain.get(0);
089    }
090
091    /**
092     * Returns the created certificate and issuer chain.
093     *
094     * @return The created end-entity {@link X509Certificate} and issuer chain. The first
095     *         certificate is always the end-entity certificate, followed by the
096     *         intermediate certificates required to build a path to a trusted root.
097     */
098    public List<X509Certificate> getCertificateChain() {
099        lazyDownload();
100        return unmodifiableList(certChain);
101    }
102
103    /**
104     * Returns URLs to alternate certificate chains.
105     *
106     * @return Alternate certificate chains, or empty if there are none.
107     */
108    public List<URL> getAlternates() {
109        lazyDownload();
110        if (alternates != null) {
111            return unmodifiableList(alternates);
112        } else {
113            return Collections.emptyList();
114        }
115    }
116
117    /**
118     * Writes the certificate to the given writer. It is written in PEM format, with the
119     * end-entity cert coming first, followed by the intermediate ceritificates.
120     *
121     * @param out
122     *            {@link Writer} to write to. The writer is not closed after use.
123     */
124    public void writeCertificate(@WillNotClose Writer out) throws IOException {
125        try {
126            for (X509Certificate cert : getCertificateChain()) {
127                AcmeUtils.writeToPem(cert.getEncoded(), AcmeUtils.PemLabel.CERTIFICATE, out);
128            }
129        } catch (CertificateEncodingException ex) {
130            throw new IOException("Encoding error", ex);
131        }
132    }
133
134    /**
135     * Revokes this certificate.
136     */
137    public void revoke() throws AcmeException {
138        revoke(null);
139    }
140
141    /**
142     * Revokes this certificate.
143     *
144     * @param reason
145     *            {@link RevocationReason} stating the reason of the revocation that is
146     *            used when generating OCSP responses and CRLs. {@code null} to give no
147     *            reason.
148     */
149    public void revoke(@Nullable RevocationReason reason) throws AcmeException {
150        revoke(getLogin(), getCertificate(), reason);
151    }
152
153    /**
154     * Revoke a certificate. This call is meant to be used for revoking certificates if
155     * only the account's key pair and the certificate itself is available.
156     *
157     * @param login
158     *            {@link Login} to the account
159     * @param cert
160     *            The {@link X509Certificate} to be revoked
161     * @param reason
162     *            {@link RevocationReason} stating the reason of the revocation that is
163     *            used when generating OCSP responses and CRLs. {@code null} to give no
164     *            reason.
165     * @since 2.6
166     */
167    public static void revoke(Login login, X509Certificate cert, @Nullable RevocationReason reason)
168                throws AcmeException {
169        LOG.debug("revoke");
170
171        Session session = login.getSession();
172
173        URL resUrl = session.resourceUrl(Resource.REVOKE_CERT);
174        if (resUrl == null) {
175            throw new AcmeException("Server does not allow certificate revocation");
176        }
177
178        try (Connection conn = session.connect()) {
179            JSONBuilder claims = new JSONBuilder();
180            claims.putBase64("certificate", cert.getEncoded());
181            if (reason != null) {
182                claims.put("reason", reason.getReasonCode());
183            }
184
185            conn.sendSignedRequest(resUrl, claims, login);
186        } catch (CertificateEncodingException ex) {
187            throw new AcmeProtocolException("Invalid certificate", ex);
188        }
189    }
190
191    /**
192     * Revoke a certificate. This call is meant to be used for revoking certificates if
193     * the account's key pair was lost.
194     *
195     * @param session
196     *            {@link Session} connected to the ACME server
197     * @param domainKeyPair
198     *            Key pair the CSR was signed with
199     * @param cert
200     *            The {@link X509Certificate} to be revoked
201     * @param reason
202     *            {@link RevocationReason} stating the reason of the revocation that is
203     *            used when generating OCSP responses and CRLs. {@code null} to give no
204     *            reason.
205     */
206    public static void revoke(Session session, KeyPair domainKeyPair, X509Certificate cert,
207            @Nullable RevocationReason reason) throws AcmeException {
208        LOG.debug("revoke using the domain key pair");
209
210        URL resUrl = session.resourceUrl(Resource.REVOKE_CERT);
211        if (resUrl == null) {
212            throw new AcmeException("Server does not allow certificate revocation");
213        }
214
215        try (Connection conn = session.connect()) {
216            JSONBuilder claims = new JSONBuilder();
217            claims.putBase64("certificate", cert.getEncoded());
218            if (reason != null) {
219                claims.put("reason", reason.getReasonCode());
220            }
221
222            conn.sendSignedRequest(resUrl, claims, session, domainKeyPair);
223        } catch (CertificateEncodingException ex) {
224            throw new AcmeProtocolException("Invalid certificate", ex);
225        }
226    }
227
228    /**
229     * Lazily downloads the certificate. Throws a runtime {@link AcmeLazyLoadingException}
230     * if the download failed.
231     */
232    private void lazyDownload() {
233        try {
234            download();
235        } catch (AcmeException ex) {
236            throw new AcmeLazyLoadingException(this, ex);
237        }
238    }
239
240}