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 net.javacrumbs.jsonunit.assertj.JsonAssertions.assertThatJson; 017import static org.assertj.core.api.Assertions.assertThat; 018import static org.shredzone.acme4j.toolbox.TestUtils.*; 019 020import java.io.ByteArrayOutputStream; 021import java.io.IOException; 022import java.io.OutputStreamWriter; 023import java.net.HttpURLConnection; 024import java.net.URL; 025import java.security.KeyPair; 026import java.security.cert.X509Certificate; 027import java.time.Instant; 028import java.time.ZonedDateTime; 029import java.time.temporal.ChronoUnit; 030import java.util.Arrays; 031import java.util.Collection; 032import java.util.Collections; 033import java.util.List; 034import java.util.Optional; 035 036import org.junit.jupiter.api.Test; 037import org.shredzone.acme4j.connector.Resource; 038import org.shredzone.acme4j.exception.AcmeException; 039import org.shredzone.acme4j.provider.TestableConnectionProvider; 040import org.shredzone.acme4j.toolbox.JSON; 041import org.shredzone.acme4j.toolbox.JSONBuilder; 042import org.shredzone.acme4j.toolbox.TestUtils; 043 044/** 045 * Unit tests for {@link Certificate}. 046 */ 047public class CertificateTest { 048 049 private final URL resourceUrl = url("http://example.com/acme/resource"); 050 private final URL locationUrl = url("http://example.com/acme/certificate"); 051 private final URL alternate1Url = url("https://example.com/acme/alt-cert/1"); 052 private final URL alternate2Url = url("https://example.com/acme/alt-cert/2"); 053 054 /** 055 * Test that a certificate can be downloaded. 056 */ 057 @Test 058 public void testDownload() throws Exception { 059 var originalCert = TestUtils.createCertificate("/cert.pem"); 060 var alternateCert = TestUtils.createCertificate("/certid-cert.pem"); 061 062 var provider = new TestableConnectionProvider() { 063 List<X509Certificate> sendCert; 064 065 @Override 066 public int sendCertificateRequest(URL url, Login login) { 067 assertThat(url).isIn(locationUrl, alternate1Url, alternate2Url); 068 assertThat(login).isNotNull(); 069 if (locationUrl.equals(url)) { 070 sendCert = originalCert; 071 } else { 072 sendCert = alternateCert; 073 } 074 return HttpURLConnection.HTTP_OK; 075 } 076 077 @Override 078 public List<X509Certificate> readCertificates() { 079 return sendCert; 080 } 081 082 @Override 083 public Collection<URL> getLinks(String relation) { 084 assertThat(relation).isEqualTo("alternate"); 085 return Arrays.asList(alternate1Url, alternate2Url); 086 } 087 }; 088 089 var cert = new Certificate(provider.createLogin(), locationUrl); 090 cert.download(); 091 092 var downloadedCert = cert.getCertificate(); 093 assertThat(downloadedCert.getEncoded()).isEqualTo(originalCert.get(0).getEncoded()); 094 095 var downloadedChain = cert.getCertificateChain(); 096 assertThat(downloadedChain).hasSize(originalCert.size()); 097 for (var ix = 0; ix < downloadedChain.size(); ix++) { 098 assertThat(downloadedChain.get(ix).getEncoded()).isEqualTo(originalCert.get(ix).getEncoded()); 099 } 100 101 byte[] writtenPem; 102 byte[] originalPem; 103 try (var baos = new ByteArrayOutputStream(); var w = new OutputStreamWriter(baos)) { 104 cert.writeCertificate(w); 105 w.flush(); 106 writtenPem = baos.toByteArray(); 107 } 108 try (var baos = new ByteArrayOutputStream(); var in = getClass().getResourceAsStream("/cert.pem")) { 109 int len; 110 var buffer = new byte[2048]; 111 while((len = in.read(buffer)) >= 0) { 112 baos.write(buffer, 0, len); 113 } 114 originalPem = baos.toByteArray(); 115 } 116 assertThat(writtenPem).isEqualTo(originalPem); 117 118 assertThat(cert.isIssuedBy("The ACME CA X1")).isFalse(); 119 assertThat(cert.isIssuedBy(CERT_ISSUER)).isTrue(); 120 121 assertThat(cert.getAlternates()).isNotNull(); 122 assertThat(cert.getAlternates()).hasSize(2); 123 assertThat(cert.getAlternates()).element(0).isEqualTo(alternate1Url); 124 assertThat(cert.getAlternates()).element(1).isEqualTo(alternate2Url); 125 126 assertThat(cert.getAlternateCertificates()).isNotNull(); 127 assertThat(cert.getAlternateCertificates()).hasSize(2); 128 assertThat(cert.getAlternateCertificates()) 129 .element(0) 130 .extracting(Certificate::getLocation) 131 .isEqualTo(alternate1Url); 132 assertThat(cert.getAlternateCertificates()) 133 .element(1) 134 .extracting(Certificate::getLocation) 135 .isEqualTo(alternate2Url); 136 137 assertThat(cert.findCertificate("The ACME CA X1")). 138 isEmpty(); 139 assertThat(cert.findCertificate(CERT_ISSUER).orElseThrow()) 140 .isSameAs(cert); 141 assertThat(cert.findCertificate("minica root ca 3a1356").orElseThrow()) 142 .isSameAs(cert.getAlternateCertificates().get(0)); 143 assertThat(cert.getAlternateCertificates().get(0).isIssuedBy("minica root ca 3a1356")) 144 .isTrue(); 145 146 provider.close(); 147 } 148 149 /** 150 * Test that a certificate can be revoked. 151 */ 152 @Test 153 public void testRevokeCertificate() throws AcmeException, IOException { 154 var originalCert = TestUtils.createCertificate("/cert.pem"); 155 156 var provider = new TestableConnectionProvider() { 157 private boolean certRequested = false; 158 159 @Override 160 public int sendCertificateRequest(URL url, Login login) { 161 assertThat(url).isEqualTo(locationUrl); 162 assertThat(login).isNotNull(); 163 certRequested = true; 164 return HttpURLConnection.HTTP_OK; 165 } 166 167 @Override 168 public int sendSignedRequest(URL url, JSONBuilder claims, Login login) { 169 assertThat(url).isEqualTo(resourceUrl); 170 assertThatJson(claims.toString()).isEqualTo(getJSON("revokeCertificateRequest").toString()); 171 assertThat(login).isNotNull(); 172 certRequested = false; 173 return HttpURLConnection.HTTP_OK; 174 } 175 176 @Override 177 public List<X509Certificate> readCertificates() { 178 assertThat(certRequested).isTrue(); 179 return originalCert; 180 } 181 182 @Override 183 public Collection<URL> getLinks(String relation) { 184 assertThat(relation).isEqualTo("alternate"); 185 return Collections.emptyList(); 186 } 187 }; 188 189 provider.putTestResource(Resource.REVOKE_CERT, resourceUrl); 190 191 var cert = new Certificate(provider.createLogin(), locationUrl); 192 cert.revoke(); 193 194 provider.close(); 195 } 196 197 /** 198 * Test that a certificate can be revoked with reason. 199 */ 200 @Test 201 public void testRevokeCertificateWithReason() throws AcmeException, IOException { 202 var originalCert = TestUtils.createCertificate("/cert.pem"); 203 204 var provider = new TestableConnectionProvider() { 205 private boolean certRequested = false; 206 207 @Override 208 public int sendCertificateRequest(URL url, Login login) { 209 assertThat(url).isEqualTo(locationUrl); 210 assertThat(login).isNotNull(); 211 certRequested = true; 212 return HttpURLConnection.HTTP_OK; 213 } 214 215 @Override 216 public int sendSignedRequest(URL url, JSONBuilder claims, Login login) { 217 assertThat(url).isEqualTo(resourceUrl); 218 assertThatJson(claims.toString()).isEqualTo(getJSON("revokeCertificateWithReasonRequest").toString()); 219 assertThat(login).isNotNull(); 220 certRequested = false; 221 return HttpURLConnection.HTTP_OK; 222 } 223 224 @Override 225 public List<X509Certificate> readCertificates() { 226 assertThat(certRequested).isTrue(); 227 return originalCert; 228 } 229 230 @Override 231 public Collection<URL> getLinks(String relation) { 232 assertThat(relation).isEqualTo("alternate"); 233 return Collections.emptyList(); 234 } 235 }; 236 237 provider.putTestResource(Resource.REVOKE_CERT, resourceUrl); 238 239 var cert = new Certificate(provider.createLogin(), locationUrl); 240 cert.revoke(RevocationReason.KEY_COMPROMISE); 241 242 provider.close(); 243 } 244 245 /** 246 * Test that numeric revocation reasons are correctly translated. 247 */ 248 @Test 249 public void testRevocationReason() { 250 assertThat(RevocationReason.code(1)) 251 .isEqualTo(RevocationReason.KEY_COMPROMISE); 252 } 253 254 /** 255 * Test that a certificate can be revoked by its domain key pair. 256 */ 257 @Test 258 public void testRevokeCertificateByKeyPair() throws AcmeException, IOException { 259 var originalCert = TestUtils.createCertificate("/cert.pem"); 260 var certKeyPair = TestUtils.createDomainKeyPair(); 261 262 var provider = new TestableConnectionProvider() { 263 @Override 264 public int sendSignedRequest(URL url, JSONBuilder claims, Session session, KeyPair keypair) { 265 assertThat(url).isEqualTo(resourceUrl); 266 assertThatJson(claims.toString()).isEqualTo(getJSON("revokeCertificateWithReasonRequest").toString()); 267 assertThat(session).isNotNull(); 268 assertThat(keypair).isEqualTo(certKeyPair); 269 return HttpURLConnection.HTTP_OK; 270 } 271 }; 272 273 provider.putTestResource(Resource.REVOKE_CERT, resourceUrl); 274 275 var session = provider.createSession(); 276 277 Certificate.revoke(session, certKeyPair, originalCert.get(0), RevocationReason.KEY_COMPROMISE); 278 279 provider.close(); 280 } 281 282 /** 283 * Test that RenewalInfo is returned. 284 */ 285 @Test 286 public void testRenewalInfo() throws AcmeException, IOException { 287 // certid-cert.pem and certId provided by draft-ietf-acme-ari-03 and known good 288 var certId = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"; 289 var certIdCert = TestUtils.createCertificate("/certid-cert.pem"); 290 var certResourceUrl = new URL(resourceUrl.toExternalForm() + "/" + certId); 291 var retryAfterInstant = Instant.now().plus(10L, ChronoUnit.DAYS); 292 293 var provider = new TestableConnectionProvider() { 294 private boolean certRequested = false; 295 private boolean infoRequested = false; 296 297 @Override 298 public int sendCertificateRequest(URL url, Login login) { 299 assertThat(url).isEqualTo(locationUrl); 300 assertThat(login).isNotNull(); 301 certRequested = true; 302 return HttpURLConnection.HTTP_OK; 303 } 304 305 @Override 306 public int sendRequest(URL url, Session session, ZonedDateTime ifModifiedSince) { 307 assertThat(url).isEqualTo(certResourceUrl); 308 assertThat(session).isNotNull(); 309 assertThat(ifModifiedSince).isNull(); 310 infoRequested = true; 311 return HttpURLConnection.HTTP_OK; 312 } 313 314 @Override 315 public JSON readJsonResponse() { 316 assertThat(infoRequested).isTrue(); 317 return getJSON("renewalInfo"); 318 } 319 320 @Override 321 public List<X509Certificate> readCertificates() { 322 assertThat(certRequested).isTrue(); 323 return certIdCert; 324 } 325 326 @Override 327 public Collection<URL> getLinks(String relation) { 328 return Collections.emptyList(); 329 } 330 331 @Override 332 public Optional<Instant> getRetryAfter() { 333 return Optional.of(retryAfterInstant); 334 } 335 }; 336 337 provider.putTestResource(Resource.RENEWAL_INFO, resourceUrl); 338 339 var cert = new Certificate(provider.createLogin(), locationUrl); 340 assertThat(cert.getCertID()).isEqualTo("MFgwCwYJYIZIAWUDBAIBBCCeWLRusNLb--vmWOkxm34qDjTMWkc3utIhOMoMwKDqbgQg2iiKWySZrD-6c88HMZ6vhIHZPamChLlzGHeZ7pTS8jYCBQCHZUMh"); 341 assertThat(cert.hasRenewalInfo()).isTrue(); 342 assertThat(cert.getRenewalInfoLocation()) 343 .hasValue(certResourceUrl); 344 345 var renewalInfo = cert.getRenewalInfo(); 346 assertThat(renewalInfo.getRetryAfter()) 347 .isEmpty(); 348 assertThat(renewalInfo.getSuggestedWindowStart()) 349 .isEqualTo("2021-01-03T00:00:00Z"); 350 assertThat(renewalInfo.getSuggestedWindowEnd()) 351 .isEqualTo("2021-01-07T00:00:00Z"); 352 assertThat(renewalInfo.getExplanation()) 353 .isNotEmpty() 354 .contains(url("https://example.com/docs/example-mass-reissuance-event")); 355 356 assertThat(renewalInfo.fetch()).hasValue(retryAfterInstant); 357 assertThat(renewalInfo.getRetryAfter()).hasValue(retryAfterInstant); 358 359 provider.close(); 360 } 361 362 /** 363 * Test that a certificate is marked as replaced. 364 */ 365 @Test 366 public void testMarkedAsReplaced() throws AcmeException, IOException { 367 // certid-cert.pem and certId provided by draft-ietf-acme-ari-03 and known good 368 var certId = "aYhba4dGQEHhs3uEe6CuLN4ByNQ.AIdlQyE"; 369 var certIdCert = TestUtils.createCertificate("/certid-cert.pem"); 370 var certResourceUrl = new URL(resourceUrl.toExternalForm() + "/" + certId); 371 372 var provider = new TestableConnectionProvider() { 373 private boolean certRequested = false; 374 375 @Override 376 public int sendCertificateRequest(URL url, Login login) { 377 assertThat(url).isEqualTo(locationUrl); 378 assertThat(login).isNotNull(); 379 certRequested = true; 380 return HttpURLConnection.HTTP_OK; 381 } 382 383 @Override 384 public int sendSignedRequest(URL url, JSONBuilder claims, Login login) { 385 assertThat(certRequested).isTrue(); 386 assertThat(url).isEqualTo(resourceUrl); 387 assertThatJson(claims.toString()).isEqualTo(getJSON("replacedCertificateRequest").toString()); 388 assertThat(login).isNotNull(); 389 return HttpURLConnection.HTTP_OK; 390 } 391 392 @Override 393 public List<X509Certificate> readCertificates() { 394 assertThat(certRequested).isTrue(); 395 return certIdCert; 396 } 397 398 @Override 399 public Collection<URL> getLinks(String relation) { 400 return Collections.emptyList(); 401 } 402 }; 403 404 provider.putTestResource(Resource.RENEWAL_INFO, resourceUrl); 405 406 var cert = new Certificate(provider.createLogin(), locationUrl); 407 assertThat(cert.hasRenewalInfo()).isTrue(); 408 assertThat(cert.getRenewalInfoLocation()).hasValue(certResourceUrl); 409 410 provider.close(); 411 } 412 413}