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;
017import static java.util.Objects.requireNonNull;
018import static java.util.stream.Collectors.toList;
019import static java.util.stream.Collectors.toUnmodifiableList;
020import static org.shredzone.acme4j.toolbox.AcmeUtils.getRenewalUniqueIdentifier;
021
022import java.io.IOException;
023import java.io.Serial;
024import java.io.Writer;
025import java.net.MalformedURLException;
026import java.net.URI;
027import java.net.URL;
028import java.security.KeyPair;
029import java.security.Principal;
030import java.security.cert.CertificateEncodingException;
031import java.security.cert.X509Certificate;
032import java.util.Collection;
033import java.util.List;
034import java.util.Optional;
035
036import edu.umd.cs.findbugs.annotations.Nullable;
037import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
038import org.shredzone.acme4j.connector.Resource;
039import org.shredzone.acme4j.exception.AcmeException;
040import org.shredzone.acme4j.exception.AcmeLazyLoadingException;
041import org.shredzone.acme4j.exception.AcmeNotSupportedException;
042import org.shredzone.acme4j.exception.AcmeProtocolException;
043import org.shredzone.acme4j.toolbox.AcmeUtils;
044import org.shredzone.acme4j.toolbox.JSONBuilder;
045import org.shredzone.acme4j.toolbox.JoseUtils;
046import org.slf4j.Logger;
047import org.slf4j.LoggerFactory;
048
049/**
050 * Represents an issued certificate and its certificate chain.
051 * <p>
052 * A certificate is immutable once it is issued. For renewal, a new certificate must be
053 * ordered.
054 */
055public class Certificate extends AcmeResource {
056    @Serial
057    private static final long serialVersionUID = 7381527770159084201L;
058    private static final Logger LOG = LoggerFactory.getLogger(Certificate.class);
059
060    private @Nullable List<X509Certificate> certChain;
061    private @Nullable Collection<URL> alternates;
062    private transient @Nullable RenewalInfo renewalInfo = null;
063    private transient @Nullable List<Certificate> alternateCerts = null;
064
065    protected Certificate(Login login, URL certUrl) {
066        super(login, certUrl);
067    }
068
069    /**
070     * Downloads the certificate chain.
071     * <p>
072     * The certificate is downloaded lazily by the other methods. Usually there is no need
073     * to invoke this method, unless the download is to be enforced. If the certificate
074     * has been downloaded already, nothing will happen.
075     *
076     * @throws AcmeException
077     *         if the certificate could not be downloaded
078     */
079    public void download() throws AcmeException {
080        if (certChain == null) {
081            LOG.debug("download");
082            try (var conn = getSession().connect()) {
083                conn.sendCertificateRequest(getLocation(), getLogin());
084                alternates = conn.getLinks("alternate");
085                certChain = conn.readCertificates();
086            }
087        }
088    }
089
090    /**
091     * Returns the created certificate.
092     *
093     * @return The created end-entity {@link X509Certificate} without issuer chain.
094     */
095    public X509Certificate getCertificate() {
096        lazyDownload();
097        return requireNonNull(certChain).get(0);
098    }
099
100    /**
101     * Returns the created certificate and issuer chain.
102     *
103     * @return The created end-entity {@link X509Certificate} and issuer chain. The first
104     *         certificate is always the end-entity certificate, followed by the
105     *         intermediate certificates required to build a path to a trusted root.
106     */
107    public List<X509Certificate> getCertificateChain() {
108        lazyDownload();
109        return unmodifiableList(requireNonNull(certChain));
110    }
111
112    /**
113     * Returns URLs to alternate certificate chains.
114     *
115     * @return Alternate certificate chains, or empty if there are none.
116     */
117    public List<URL> getAlternates() {
118        lazyDownload();
119        return requireNonNull(alternates).stream().collect(toUnmodifiableList());
120    }
121
122    /**
123     * Returns alternate certificate chains, if available.
124     *
125     * @return Alternate certificate chains, or empty if there are none.
126     * @since 2.11
127     */
128    public List<Certificate> getAlternateCertificates() {
129        if (alternateCerts == null) {
130            var login = getLogin();
131            alternateCerts = getAlternates().stream()
132                    .map(login::bindCertificate)
133                    .collect(toList());
134        }
135        return unmodifiableList(alternateCerts);
136    }
137
138    /**
139     * Checks if this certificate was issued by the given issuer name.
140     *
141     * @param issuer
142     *         Issuer name to check against, case-sensitive
143     * @return {@code true} if this issuer name was found in the certificate chain as
144     * issuer, {@code false} otherwise.
145     * @since 3.0.0
146     */
147    public boolean isIssuedBy(String issuer) {
148        var issuerCn = "CN=" + issuer;
149        return getCertificateChain().stream()
150                .map(X509Certificate::getIssuerX500Principal)
151                .map(Principal::getName)
152                .anyMatch(issuerCn::equals);
153    }
154
155    /**
156     * Finds a {@link Certificate} that was issued by the given issuer name.
157     *
158     * @param issuer
159     *         Issuer name to check against, case-sensitive
160     * @return Certificate that was issued by that issuer, or {@code empty} if there was
161     * none. The returned {@link Certificate} may be this instance, or one of the
162     * {@link #getAlternateCertificates()} instances. If multiple certificates are issued
163     * by that issuer, the first one that was found is returned.
164     * @since 3.0.0
165     */
166    public Optional<Certificate> findCertificate(String issuer) {
167        if (isIssuedBy(issuer)) {
168            return Optional.of(this);
169        }
170        return getAlternateCertificates().stream()
171                .filter(c -> c.isIssuedBy(issuer))
172                .findFirst();
173    }
174
175    /**
176     * Writes the certificate to the given writer. It is written in PEM format, with the
177     * end-entity cert coming first, followed by the intermediate certificates.
178     *
179     * @param out
180     *            {@link Writer} to write to. The writer is not closed after use.
181     */
182    public void writeCertificate(Writer out) throws IOException {
183        try {
184            for (var cert : getCertificateChain()) {
185                AcmeUtils.writeToPem(cert.getEncoded(), AcmeUtils.PemLabel.CERTIFICATE, out);
186            }
187        } catch (CertificateEncodingException ex) {
188            throw new IOException("Encoding error", ex);
189        }
190    }
191
192    /**
193     * Returns the location of the certificate's RenewalInfo. Empty if the CA does not
194     * provide this information.
195     *
196     * @since 3.0.0
197     */
198    public Optional<URL> getRenewalInfoLocation() {
199        try {
200            return getSession().resourceUrlOptional(Resource.RENEWAL_INFO)
201                    .map(baseUrl -> {
202                        try {
203                            var url = baseUrl.toExternalForm();
204                            if (!url.endsWith("/")) {
205                                url += '/';
206                            }
207                            url += getRenewalUniqueIdentifier(getCertificate());
208                            return URI.create(url).toURL();
209                        } catch (MalformedURLException ex) {
210                            throw new AcmeProtocolException("Invalid RenewalInfo URL", ex);
211                        }
212                    });
213        } catch (AcmeException ex) {
214            throw new AcmeLazyLoadingException(this, ex);
215        }
216    }
217
218    /**
219     * Returns {@code true} if the CA provides renewal information.
220     *
221     * @since 3.0.0
222     */
223    public boolean hasRenewalInfo() {
224        return getRenewalInfoLocation().isPresent();
225    }
226
227    /**
228     * Reads the RenewalInfo for this certificate.
229     *
230     * @return The {@link RenewalInfo} of this certificate.
231     * @throws AcmeNotSupportedException if the CA does not support renewal information.
232     * @since 3.0.0
233     */
234    @SuppressFBWarnings("EI_EXPOSE_REP")   // behavior is intended
235    public RenewalInfo getRenewalInfo() {
236        if (renewalInfo == null) {
237            renewalInfo = getRenewalInfoLocation()
238                    .map(getLogin()::bindRenewalInfo)
239                    .orElseThrow(() -> new AcmeNotSupportedException("renewal-info"));
240        }
241        return renewalInfo;
242    }
243
244    /**
245     * Revokes this certificate.
246     */
247    public void revoke() throws AcmeException {
248        revoke(null);
249    }
250
251    /**
252     * Revokes this certificate.
253     *
254     * @param reason
255     *            {@link RevocationReason} stating the reason of the revocation that is
256     *            used when generating OCSP responses and CRLs. {@code null} to give no
257     *            reason.
258     * @see #revoke(Login, X509Certificate, RevocationReason)
259     * @see #revoke(Session, KeyPair, X509Certificate, RevocationReason)
260     */
261    public void revoke(@Nullable RevocationReason reason) throws AcmeException {
262        revoke(getLogin(), getCertificate(), reason);
263    }
264
265    /**
266     * Revoke a certificate.
267     * <p>
268     * Use this method if the certificate's location is unknown, so you cannot regenerate
269     * a {@link Certificate} instance. This method requires a {@link Login} to your
270     * account and the issued certificate.
271     *
272     * @param login
273     *         {@link Login} to the account
274     * @param cert
275     *         The {@link X509Certificate} to be revoked
276     * @param reason
277     *         {@link RevocationReason} stating the reason of the revocation that is used
278     *         when generating OCSP responses and CRLs. {@code null} to give no reason.
279     * @see #revoke(Session, KeyPair, X509Certificate, RevocationReason)
280     * @since 2.6
281     */
282    public static void revoke(Login login, X509Certificate cert, @Nullable RevocationReason reason)
283                throws AcmeException {
284        LOG.debug("revoke");
285
286        var session = login.getSession();
287
288        var resUrl = session.resourceUrl(Resource.REVOKE_CERT);
289
290        try (var conn = session.connect()) {
291            var claims = new JSONBuilder();
292            claims.putBase64("certificate", cert.getEncoded());
293            if (reason != null) {
294                claims.put("reason", reason.getReasonCode());
295            }
296
297            conn.sendSignedRequest(resUrl, claims, login);
298        } catch (CertificateEncodingException ex) {
299            throw new AcmeProtocolException("Invalid certificate", ex);
300        }
301    }
302
303    /**
304     * Revoke a certificate.
305     * <p>
306     * Use this method if the key pair of your account was lost (so you are unable to
307     * login into your account), but you still have the key pair of the affected domain
308     * and the issued certificate.
309     *
310     * @param session
311     *         {@link Session} connected to the ACME server
312     * @param domainKeyPair
313     *         Key pair the CSR was signed with
314     * @param cert
315     *         The {@link X509Certificate} to be revoked
316     * @param reason
317     *         {@link RevocationReason} stating the reason of the revocation that is used
318     *         when generating OCSP responses and CRLs. {@code null} to give no reason.
319     * @see #revoke(Login, X509Certificate, RevocationReason)
320     */
321    public static void revoke(Session session, KeyPair domainKeyPair, X509Certificate cert,
322            @Nullable RevocationReason reason) throws AcmeException {
323        LOG.debug("revoke using the domain key pair");
324
325        var resUrl = session.resourceUrl(Resource.REVOKE_CERT);
326
327        try (var conn = session.connect()) {
328            var claims = new JSONBuilder();
329            claims.putBase64("certificate", cert.getEncoded());
330            if (reason != null) {
331                claims.put("reason", reason.getReasonCode());
332            }
333
334            conn.sendSignedRequest(resUrl, claims, session, (url, payload, nonce) ->
335                    JoseUtils.createJoseRequest(url, domainKeyPair, payload, nonce, null));
336
337        } catch (CertificateEncodingException ex) {
338            throw new AcmeProtocolException("Invalid certificate", ex);
339        }
340    }
341
342    /**
343     * Lazily downloads the certificate. Throws a runtime {@link AcmeLazyLoadingException}
344     * if the download failed.
345     */
346    private void lazyDownload() {
347        try {
348            download();
349        } catch (AcmeException ex) {
350            throw new AcmeLazyLoadingException(this, ex);
351        }
352    }
353
354}