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