View Javadoc
1   /*
2    * Copyright 2024 The Netty Project
3    *
4    * The Netty Project licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  package io.netty.pkitesting;
17  
18  import com.sun.net.httpserver.HttpServer;
19  
20  import java.io.OutputStream;
21  import java.math.BigInteger;
22  import java.net.InetAddress;
23  import java.net.InetSocketAddress;
24  import java.net.URI;
25  import java.security.cert.X509Certificate;
26  import java.time.Instant;
27  import java.util.Collections;
28  import java.util.Map;
29  import java.util.concurrent.ConcurrentHashMap;
30  import java.util.concurrent.ConcurrentMap;
31  import java.util.concurrent.ForkJoinPool;
32  import java.util.concurrent.atomic.AtomicInteger;
33  
34  /**
35   * A simple HTTP server that serves Certificate Revocation Lists.
36   * <p>
37   * Issuer certificates can be registered with the server, and revocations of their certificates and be published
38   * and added to the revocation lists.
39   * <p>
40   * The server is only intended for testing usage, and runs entirely in a single thread.
41   *
42   * @implNote The CRLs will have the same very short life times, to minimize caching effects in tests.
43   * This currently means the time in the "this update" and "next update" fields are set to the same value.
44   */
45  public final class RevocationServer {
46      private static volatile RevocationServer instance;
47  
48      private final HttpServer crlServer;
49      private final String crlBaseAddress;
50      private final AtomicInteger issuerCounter;
51      private final ConcurrentMap<X509Certificate, CrlInfo> issuers;
52      private final ConcurrentMap<String, CrlInfo> paths;
53  
54      /**
55       * Get the shared revocation server instance.
56       * This will start the server, if it isn't already running, and bind it to a random port on the loopback address.
57       * @return The revocation server instance.
58       * @throws Exception If the server failed to start.
59       */
60      public static RevocationServer getInstance() throws Exception {
61          if (instance != null) {
62              return instance;
63          }
64          synchronized (RevocationServer.class) {
65              RevocationServer server = instance;
66              if (server == null) {
67                  server = new RevocationServer();
68                  server.start();
69                  instance = server;
70              }
71              return server;
72          }
73      }
74  
75      private RevocationServer() throws Exception {
76          // Use the JDK built-in HttpServer to avoid any circular dependencies with Netty itself.
77          crlServer = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
78          crlBaseAddress = "http://localhost:" + crlServer.getAddress().getPort();
79          issuerCounter = new AtomicInteger();
80          issuers = new ConcurrentHashMap<>();
81          paths = new ConcurrentHashMap<>();
82          crlServer.createContext("/", exchange -> {
83              if ("GET".equals(exchange.getRequestMethod())) {
84                  String path = exchange.getRequestURI().getPath();
85                  CrlInfo info = paths.get(path);
86                  if (info == null) {
87                      exchange.sendResponseHeaders(404, 0);
88                      exchange.close();
89                      return;
90                  }
91                  byte[] crl = generateCrl(info);
92                  exchange.getResponseHeaders().put("Content-Type", Collections.singletonList("application/pkix-crl"));
93                  exchange.sendResponseHeaders(200, crl.length);
94                  try (OutputStream out = exchange.getResponseBody()) {
95                      out.write(crl);
96                      out.flush();
97                  }
98              } else {
99                  exchange.sendResponseHeaders(405, 0);
100             }
101             exchange.close();
102         });
103     }
104 
105     private void start() {
106         if (Thread.currentThread().isDaemon()) {
107             crlServer.start();
108         } else {
109             // It's important the CRL server creates a daemon thread,
110             // because it's a singleton and won't be stopped except by terminating the JVM.
111             // Threads in the ForkJoin common pool are always daemon, and JUnit 5 initializes
112             // it anyway, so we can let it call start() for us.
113             ForkJoinPool.commonPool().execute(crlServer::start);
114         }
115     }
116 
117     /**
118      * Register an issuer with the revocation server.
119      * This must be done before CRLs can be served for that issuer, and before any of its certificates can be revoked.
120      * @param issuer The issuer to register.
121      */
122     public void register(X509Bundle issuer) {
123         issuers.computeIfAbsent(issuer.getCertificate(), bundle -> {
124             String path = "/crl/" + issuerCounter.incrementAndGet() + ".crl";
125             URI uri = URI.create(crlBaseAddress + path);
126             CrlInfo info = new CrlInfo(issuer, uri);
127             paths.put(path, info);
128             return info;
129         });
130     }
131 
132     /**
133      * Revoke the given certificate with the given revocation time.
134      * <p>
135      * The issuer of the given certificate must be {@linkplain #register(X509Bundle) registered} before its certifiactes
136      * can be revoked.
137      * @param cert The certificate to revoke.
138      * @param time The time of revocation.
139      */
140     public void revoke(X509Bundle cert, Instant time) {
141         X509Certificate[] certPath = cert.getCertificatePathWithRoot();
142         X509Certificate issuer = certPath.length == 1 ? certPath[0] : certPath[1];
143         CrlInfo info = issuers.get(issuer);
144         if (info != null) {
145             info.revokedCerts.put(cert.getCertificate().getSerialNumber(), time);
146         } else {
147             throw new IllegalArgumentException("Not a registered issuer: " + issuer.getSubjectX500Principal());
148         }
149     }
150 
151     /**
152      * Get the URI of the Certificate Revocation List for the given issuer.
153      * @param issuer The issuer to get the CRL for.
154      * @return The URI to the CRL for the given issuer,
155      * or {@code null} if the issuer is not {@linkplain #register(X509Bundle) registered}.
156      */
157     public URI getCrlUri(X509Bundle issuer) {
158         CrlInfo info = issuers.get(issuer.getCertificate());
159         if (info != null) {
160             return info.uri;
161         }
162         return null;
163     }
164 
165     private static byte[] generateCrl(CrlInfo info) {
166         X509Bundle issuer = info.issuer;
167         Map<BigInteger, Instant> certs = info.revokedCerts;
168         Instant now = Instant.now();
169         CertificateList list = new CertificateList(issuer, now, now, certs.entrySet());
170         try {
171             Signed signed = new Signed(list.getEncoded(), issuer);
172             return signed.getEncoded();
173         } catch (Exception e) {
174             throw new IllegalStateException("Failed to sign CRL", e);
175         }
176     }
177 
178     private static final class CrlInfo {
179         private final X509Bundle issuer;
180         private final URI uri;
181         private final Map<BigInteger, Instant> revokedCerts;
182 
183         CrlInfo(X509Bundle issuer, URI uri) {
184             this.issuer = issuer;
185             this.uri = uri;
186             revokedCerts = new ConcurrentHashMap<>();
187         }
188     }
189 }