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}