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}