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