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}