GoogleCloudSigningService.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.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyStoreException;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

import net.jsign.DigestAlgorithm;

/**
 * Signing service using the Google Cloud Key Management API.
 *
 * <p>The key alias can take one of the following forms:</p>
 *  <ul>
 *   <li>The absolute path of the key with the exact version specified:
 *       <tt>projects/first-rain-123/locations/global/keyRings/mykeyring/cryptoKeys/mykey/cryptoKeyVersions/2</tt></li>
 *   <li>The absolute path of the key without the version specified, the first version enabled will be used:
 *       <tt>projects/first-rain-123/locations/global/keyRings/mykeyring/cryptoKeys/mykey</tt></li>
 *   <li>The path of the key relatively to the keyring with the version specified: <tt>mykey/cryptoKeyVersions/2</tt></li>
 *   <li>The path of the key relatively to the keyring without the version specified: <tt>mykey</tt></li>
 * </ul>
 *
 * @since 4.0
 * @see <a href="https://cloud.google.com/kms/docs/reference/rest">Cloud Key Management Service (KMS) API</a>
 */
public class GoogleCloudSigningService implements SigningService {

    /** The name of the keyring */
    private final String keyring;

    /** Source for the certificates */
    private final Function<String, Certificate[]> certificateStore;

    /** Cache of private keys indexed by id */
    private final Map<String, SigningServicePrivateKey> keys = new HashMap<>();

    private final RESTClient client;

    /**
     * Creates a new Google Cloud signing service.
     *
     * @param keyring          the path of the keyring (for example <tt>projects/first-rain-123/locations/global/keyRings/mykeyring</tt>)
     * @param token            the Google Cloud API access token
     * @param certificateStore provides the certificate chain for the keys
     */
    public GoogleCloudSigningService(String keyring, String token, Function<String, Certificate[]> certificateStore) {
        this("https://cloudkms.googleapis.com/v1/", keyring, token, certificateStore);
    }

    GoogleCloudSigningService(String endpoint, String keyring, String token, Function<String, Certificate[]> certificateStore) {
        this.keyring = keyring;
        this.certificateStore = certificateStore;
        this.client = new RESTClient(endpoint)
                .authentication(conn -> conn.setRequestProperty("Authorization", "Bearer " + token))
                .errorHandler(response -> {
                    StringBuilder message = new StringBuilder();
                    if (response.get("error") instanceof Map) {
                        Map error = (Map) response.get("error");
                        if (error.get("code") != null) {
                            message.append(error.get("code"));
                        }
                        if (error.get("status") != null) {
                            if (message.length() > 0) {
                                message.append(" - ");
                            }
                            message.append(error.get("status"));
                        }
                        if (error.get("message") != null) {
                            if (message.length() > 0) {
                                message.append(": ");
                            }
                            message.append(error.get("message"));
                        }
                    }
                    return message.toString();
                });
    }

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

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

        try {
            Map<String, ?> response = client.get(keyring + "/cryptoKeys");
            Object[] cryptoKeys = (Object[]) response.get("cryptoKeys");
            for (Object cryptoKey : cryptoKeys) {
                String name = (String) ((Map) cryptoKey).get("name");
                aliases.add(name.substring(name.lastIndexOf("/") + 1));
            }
        } catch (IOException e) {
            throw new KeyStoreException(e);
        }

        return aliases;
    }

    @Override
    public Certificate[] getCertificateChain(String alias) {
        return certificateStore.apply(alias);
    }

    @Override
    public SigningServicePrivateKey getPrivateKey(String alias, char[] password) throws UnrecoverableKeyException {
        // check if the alias is absolute or relative to the keyring
        if (!alias.startsWith("projects/")) {
            alias = keyring + "/cryptoKeys/" + alias;
        }

        if (keys.containsKey(alias)) {
            return keys.get(alias);
        }

        String algorithm;

        try {
            if (alias.contains("cryptoKeyVersions")) {
                // full key with version specified
                if (alias.contains(":")) {
                    // syntax with the algorithm appended to the alias
                    algorithm = alias.substring(alias.indexOf(':') + 1) + "_SIGN";
                    alias = alias.substring(0, alias.indexOf(':'));
                } else {
                    Certificate[] chain = getCertificateChain(alias);
                    if (chain != null && chain.length > 0) {
                        Certificate certificate = chain[0];
                        algorithm = certificate.getPublicKey().getAlgorithm() + "_SIGN";
                    } else {
                        Map<String, ?> response = client.get(alias);
                        algorithm = (String) response.get("algorithm");
                    }
                }
            } else {
                // key version not specified, find the most recent
                Map<String, ?> response = client.get(alias + "/cryptoKeyVersions?filter=state%3DENABLED");
                Object[] cryptoKeyVersions = (Object[]) response.get("cryptoKeyVersions");
                if (cryptoKeyVersions == null || cryptoKeyVersions.length == 0) {
                    throw new UnrecoverableKeyException("Unable to fetch Google Cloud private key '" + alias + "', no version found");
                }

                Map<String, ?> cryptoKeyVersion = (Map) cryptoKeyVersions[cryptoKeyVersions.length - 1];
                alias = (String) cryptoKeyVersion.get("name");
                algorithm = (String) cryptoKeyVersion.get("algorithm");
            }
        } catch (IOException e) {
            throw (UnrecoverableKeyException) new UnrecoverableKeyException("Unable to fetch Google Cloud private key '" + alias + "'").initCause(e);
        }

        algorithm = algorithm.substring(0, algorithm.indexOf("_")); // RSA_SIGN_PKCS1_2048_SHA256 -> RSA

        SigningServicePrivateKey key = new SigningServicePrivateKey(alias, algorithm, this);
        keys.put(alias, key);
        keys.put(alias.substring(0, alias.indexOf("/cryptoKeyVersions")), key); // cache without the version
        keys.put(alias + ":" + algorithm, key); // cache with the algorithm appended
        return key;
    }

    @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, String> digest = new HashMap<>();
        digest.put(digestAlgorithm.name().toLowerCase(), Base64.getEncoder().encodeToString(data));
        Map<String, Object> request = new HashMap<>();
        request.put("digest", digest);

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

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