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