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