DigiCertOneSigningService.java

/*
 * Copyright 2021 Emmanuel Bourg
 *
 * Licensed 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 net.jsign.jca;

import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.SecureRandom;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.X509KeyManager;

import net.jsign.DigestAlgorithm;
import net.jsign.KeyStoreBuilder;

/**
 * DigiCert ONE signing service.
 *
 * @since 4.0
 * @see <a href="https://one.digicert.com/signingmanager/docs/swagger-ui/index.html">Software Trust Manager REST API</a>
 */
public class DigiCertOneSigningService implements SigningService {

    /** Cache of certificates indexed by id and alias */ 
    private final Map<String, Map<String, ?>> certificates = new HashMap<>();

    private final RESTClient client;

    /** Pattern of a certificate or key identifier */
    private static final Pattern ID_PATTERN = Pattern.compile("[0-9a-f\\-]+");

    /**
     * Creates a new DigiCert ONE signing service.
     *
     * @param apiKey    the DigiCert ONE API access token
     * @param keystore  the keystore holding the client certificate to authenticate with the server
     * @param storepass the password of the keystore
     */
    public DigiCertOneSigningService(String apiKey, File keystore, String storepass) {
        this(apiKey, (X509KeyManager) getKeyManager(keystore, storepass));
    }

    /**
     * Creates a new DigiCert ONE signing service.
     *
     * @param endpoint  the URL of the DigiCert ONE host
     * @param apiKey    the DigiCert ONE API access token
     * @param keystore  the keystore holding the client certificate to authenticate with the server
     * @param storepass the password of the keystore
     */
    public DigiCertOneSigningService(String endpoint, String apiKey, File keystore, String storepass) {
        this(endpoint, apiKey, (X509KeyManager) getKeyManager(keystore, storepass));
    }

    /**
     * Creates a new DigiCert ONE signing service.
     *
     * @param apiKey     the DigiCert ONE API access token
     * @param keyManager the key manager to authenticate the client with the server
     */
    public DigiCertOneSigningService(String apiKey, X509KeyManager keyManager) {
        this(null, apiKey, keyManager);
    }

    DigiCertOneSigningService(String endpoint, String apiKey, X509KeyManager keyManager) {
        if (endpoint == null) {
            endpoint = "https://clientauth.one.digicert.com";
        }
        this.client = new RESTClient(endpoint + "/signingmanager/api/v1/")
                .authentication(conn -> {
                    conn.setRequestProperty("x-api-key", apiKey);
                    try {
                        SSLContext context = SSLContext.getInstance("TLS");
                        context.init(new KeyManager[]{keyManager}, null, new SecureRandom());
                        if (conn instanceof HttpsURLConnection) {
                            ((HttpsURLConnection) conn).setSSLSocketFactory(context.getSocketFactory());
                        }
                    } catch (GeneralSecurityException e) {
                        throw new RuntimeException("Unable to load the DigiCert ONE client certificate", e);
                    }
                })
                .errorHandler(response -> {
                    Object error = response.get("error");
                    if (error instanceof Map) {
                        return ((Map) error).get("status") + ": " + ((Map) error).get("message");
                    } else if (error instanceof String) {
                        return (String) error;
                    } else {
                        return JsonWriter.format(response);
                    }
                });
    }

    @Override
    public String getName() {
        return "DigiCertONE";
    }

    /**
     * Returns the certificate details
     *
     * @param alias the id or alias of the certificate
     */
    private Map<String, ?> getCertificateInfo(String alias) throws IOException {
        if (!certificates.containsKey(alias)) {
            Map<String, ?> response = client.get("certificates?" + (isIdentifier(alias) ? "id" : "alias") + "=" + alias);
            for (Object item : (Object[]) response.get("items")) {
                Map<String, ?> certificate = (Map<String, ?>) item;
                certificates.put((String) certificate.get("id"), certificate);
                certificates.put((String) certificate.get("alias"), certificate);
            }
        }

        return certificates.get(alias);
    }

    private boolean isIdentifier(String id) {
        return ID_PATTERN.matcher(id).matches();
    }

    @Override
    public List<String> aliases() throws KeyStoreException {
        List<String> aliases = new ArrayList<>();

        try {
            Map<String, ?> response = client.get("certificates?limit=100&certificate_status=ACTIVE");
            for (Object item : (Object[]) response.get("items")) {
                Map<String, ?> certificate = (Map<String, ?>) item;
                certificates.put((String) certificate.get("id"), certificate);
                certificates.put((String) certificate.get("alias"), certificate);

                aliases.add((String) certificate.get("alias"));
            }
        } catch (IOException e) {
            throw new KeyStoreException("Unable to retrieve DigiCert ONE certificate aliases", e);
        }

        return aliases;
    }

    @Override
    public Certificate[] getCertificateChain(String alias) throws KeyStoreException {
        try {
            Map<String, ?> response = getCertificateInfo(alias);
            if (response == null) {
                throw new KeyStoreException("Unable to retrieve DigiCert ONE certificate '" + alias + "'");
            }

            List<String> encodedChain = new ArrayList<>();
            encodedChain.add((String) response.get("cert"));

            if (response.get("chain") != null) {
                for (Object certificate : (Object[]) response.get("chain")) {
                    encodedChain.add(((Map<String, String>) certificate).get("blob"));
                }
            }

            List<Certificate> chain = new ArrayList<>();
            for (String encodedCertificate : encodedChain) {
                chain.add(CertificateFactory.getInstance("X.509").generateCertificate(new ByteArrayInputStream(Base64.getDecoder().decode(encodedCertificate))));
            }
            return chain.toArray(new Certificate[0]);
        } catch (IOException | CertificateException e) {
            throw new KeyStoreException("Unable to retrieve DigiCert ONE certificate '" + alias + "'", e);
        }
    }

    @Override
    public SigningServicePrivateKey getPrivateKey(String alias, char[] password) throws UnrecoverableKeyException {
        try {
            Map<String, ?> certificate = getCertificateInfo(alias);
            Map<String, Object> keypair = (Map<String, Object>) certificate.get("keypair");
            String keyId = (String) keypair.get("id");

            Map<String, ?> response = client.get("/keypairs/" + keyId);
            String algorithm = (String) response.get("key_alg");

            SigningServicePrivateKey key = new SigningServicePrivateKey(keyId, algorithm, this);
            key.getProperties().put("account", response.get("account"));
            return key;
        } catch (IOException e) {
            throw (UnrecoverableKeyException) new UnrecoverableKeyException("Unable to fetch DigiCert ONE private key for the certificate '" + alias + "'").initCause(e);
        }
    }

    @Override
    public byte[] sign(SigningServicePrivateKey privateKey, String algorithm, byte[] data) throws GeneralSecurityException {
        DigestAlgorithm digestAlgorithm = DigestAlgorithm.of(algorithm.substring(0, algorithm.toLowerCase().indexOf("with")));
        data = digestAlgorithm.getMessageDigest().digest(data);

        Map<String, Object> request = new HashMap<>();
        request.put("account", privateKey.getProperties().get("account"));
        request.put("sig_alg", algorithm);
        request.put("hash", Base64.getEncoder().encodeToString(data));

        try {
            Map<String, ?> response = client.post("keypairs/" + privateKey.getId() + "/sign", JsonWriter.format(request));
            String value = (String) response.get("signature");

            return Base64.getDecoder().decode(value);
        } catch (IOException e) {
            throw new GeneralSecurityException(e);
        }
    }

    static KeyManager getKeyManager(File keystoreFile, String storepass) {
        try {
            KeyStore keystore = new KeyStoreBuilder().keystore(keystoreFile).storepass(storepass).build();

            KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
            kmf.init(keystore, storepass.toCharArray());

            return kmf.getKeyManagers()[0];
        } catch (Exception e) {
            throw new RuntimeException("Failed to load the client certificate for DigiCert ONE", e);
        }
    }
}