Ca.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.zookeeper.common.ssl;

import com.sun.net.httpserver.HttpServer;
import java.io.FileWriter;
import java.math.BigInteger;
import java.net.InetSocketAddress;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.security.KeyPair;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicLong;
import org.apache.zookeeper.common.X509TestHelpers;
import org.bouncycastle.asn1.ASN1GeneralizedTime;
import org.bouncycastle.asn1.ocsp.RevokedInfo;
import org.bouncycastle.asn1.x509.CRLNumber;
import org.bouncycastle.asn1.x509.CRLReason;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.cert.X509CRLHolder;
import org.bouncycastle.cert.X509v2CRLBuilder;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import org.bouncycastle.cert.jcajce.JcaX509v2CRLBuilder;
import org.bouncycastle.openssl.MiscPEMGenerator;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.bouncycastle.util.io.pem.PemWriter;

public class Ca implements AutoCloseable {
    public static class CaBuilder {
        private final Path dir;
        private String name = "CA";
        private boolean ocsp = false;

        CaBuilder(Path dir) {
            this.dir = dir;
        }

        public CaBuilder withName(String name) {
            this.name = Objects.requireNonNull(name);
            return this;
        }

        public CaBuilder withOcsp() {
            this.ocsp = true;
            return this;
        }

        public Ca build() throws Exception {
            KeyPair caKey = X509TestHelpers.generateRSAKeyPair();
            X509Certificate caCert = X509TestHelpers.newSelfSignedCert(name, caKey);
            if (ocsp) {
                HttpServer ocspServer = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
                Ca ca = new Ca(dir, name, caKey, caCert, ocspServer);
                ca.ocspServer.createContext("/", new OCSPHandler(ca));
                ca.ocspServer.start();
                return ca;
            }
            return new Ca(dir, name, caKey, caCert, null);
        }
    }

    public final Path dir;
    public final String name;
    public final KeyPair key;
    public final X509Certificate cert;
    public final Map<X509Certificate, RevokedInfo> crlRevokedCerts = Collections.synchronizedMap(new HashMap<>());
    public final Map<X509Certificate, RevokedInfo> ocspRevokedCerts = Collections.synchronizedMap(new HashMap<>());
    public final HttpServer ocspServer;
    public final AtomicLong crlNumber = new AtomicLong(1);
    public final PemFile pemFile;

    Ca(Path dir, String name, KeyPair key, X509Certificate cert, HttpServer ocspServer) throws Exception {
        this.dir = dir;
        this.name = name;
        this.key = key;
        this.cert = cert;
        this.ocspServer = ocspServer;
        this.pemFile = writePem();
    }

    private PemFile writePem() throws Exception {
        String pem = X509TestHelpers.pemEncodeX509Certificate(cert);
        Path file = Files.createTempFile(dir, name, ".pem");
        Files.write(file, pem.getBytes());
        return new PemFile(file, "");
    }

    // Check result of crldp could be cached, so use per-cert crl file.
    public void flush_crl(Cert cert) throws Exception {
        Objects.requireNonNull(cert.crl, "cert is signed with no crldp");
        Instant now = Instant.now();

        X509v2CRLBuilder builder = new JcaX509v2CRLBuilder(cert.cert.getIssuerX500Principal(), Date.from(now));
        builder.setNextUpdate(Date.from(now.plusSeconds(2)));

        builder.addExtension(Extension.authorityKeyIdentifier, false, new JcaX509ExtensionUtils().createAuthorityKeyIdentifier(this.cert));
        builder.addExtension(Extension.cRLNumber, false, new CRLNumber(BigInteger.valueOf(crlNumber.getAndAdd(1L))));

        for (Map.Entry<X509Certificate, RevokedInfo> entry : crlRevokedCerts.entrySet()) {
            builder.addCRLEntry(entry.getKey().getSerialNumber(), entry.getValue().getRevocationTime().getDate(), CRLReason.cACompromise);
        }

        ContentSigner contentSigner = new JcaContentSignerBuilder("SHA256WithRSAEncryption").build(this.key.getPrivate());
        X509CRLHolder crlHolder = builder.build(contentSigner);

        Path tmpFile = Files.createTempFile(dir, "crldp-", ".pem.tmp");
        PemWriter pemWriter = new PemWriter(new FileWriter(tmpFile.toFile()));
        pemWriter.writeObject(new MiscPEMGenerator(crlHolder));
        pemWriter.flush();
        pemWriter.close();

        Files.move(tmpFile, cert.crl, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
    }

    public void revoke_through_crldp(Cert cert) throws Exception {
        Date now = new Date();
        RevokedInfo revokedInfo = new RevokedInfo(new ASN1GeneralizedTime(now), CRLReason.lookup(CRLReason.cACompromise));
        this.crlRevokedCerts.put(cert.cert, revokedInfo);
        flush_crl(cert);
    }

    public void revoke_through_ocsp(X509Certificate cert) throws Exception {
        Date now = new Date();
        RevokedInfo revokedInfo = new RevokedInfo(new ASN1GeneralizedTime(now), CRLReason.lookup(CRLReason.cACompromise));
        this.ocspRevokedCerts.put(cert, revokedInfo);
    }

    public CertSigner signer(String name) throws Exception {
        return new CertSigner(this, name);
    }

    public Cert sign(String name) throws Exception {
        return signer(name).sign();
    }

    public Cert sign_with_crldp(String name) throws Exception {
        return signer(name).withCrldp().sign();
    }

    public Cert sign_with_ocsp(String name) throws Exception {
        return signer(name).withOcsp().sign();
    }

    public static CaBuilder builder(Path dir) {
        return new CaBuilder(dir);
    }

    public static Ca create(Path dir) throws Exception {
        return Ca.builder(dir).build();
    }

    public static Ca create(String name, Path dir) throws Exception {
        return Ca.builder(dir).withName(name).build();
    }

    public String getOcspAddress() {
        if (ocspServer != null) {
            return String.format("http://127.0.0.1:%d", ocspServer.getAddress().getPort());
        }
        throw new IllegalStateException("No OCSP server available");
    }

    @Override
    public void close() throws Exception {
        if (ocspServer != null) {
            ocspServer.stop(0);
        }
    }
}