001/**
002 * Copyright 2021 Emmanuel Bourg
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package net.jsign.jca;
018
019import java.io.IOException;
020import java.security.GeneralSecurityException;
021import java.security.KeyStoreException;
022import java.security.UnrecoverableKeyException;
023import java.security.cert.Certificate;
024import java.util.ArrayList;
025import java.util.Base64;
026import java.util.HashMap;
027import java.util.List;
028import java.util.Map;
029import java.util.function.Function;
030
031import com.cedarsoftware.util.io.JsonWriter;
032
033import net.jsign.DigestAlgorithm;
034
035/**
036 * Signing service using the Google Cloud Key Management API.
037 *
038 * <p>The key alias can take one of the following forms:</p>
039 *  <ul>
040 *   <li>The absolute path of the key with the exact version specified:
041 *       <tt>projects/first-rain-123/locations/global/keyRings/mykeyring/cryptoKeys/mykey/cryptoKeyVersions/2</tt></li>
042 *   <li>The absolute path of the key without the version specified, the first version enabled will be used:
043 *       <tt>projects/first-rain-123/locations/global/keyRings/mykeyring/cryptoKeys/mykey</tt></li>
044 *   <li>The path of the key relatively to the keyring with the version specified: <tt>mykey/cryptoKeyVersions/2</tt></li>
045 *   <li>The path of the key relatively to the keyring without the version specified: <tt>mykey</tt></li>
046 * </ul>
047 *
048 * <p>When the version of the key is specified, it's also possible to append the algorithm of the key, this saves
049 * a round-trip and reduces the risk of hitting a read request limit when signing a large number of files:
050 * <tt>mykey/cryptoKeyVersions/2:ECDSA</tt></p>
051 *
052 * @since 4.0
053 * @see <a href="https://cloud.google.com/kms/docs/reference/rest">Cloud Key Management Service (KMS) API</a>
054 */
055public class GoogleCloudSigningService implements SigningService {
056
057    /** The name of the keyring */
058    private final String keyring;
059
060    /** Source for the certificates */
061    private final Function<String, Certificate[]> certificateStore;
062
063    /** Cache of private keys indexed by id */
064    private final Map<String, SigningServicePrivateKey> keys = new HashMap<>();
065
066    private final RESTClient client;
067
068    /**
069     * Creates a new Google Cloud signing service.
070     *
071     * @param keyring          the path of the keyring (for example <tt>projects/first-rain-123/locations/global/keyRings/mykeyring</tt>)
072     * @param token            the Google Cloud API access token
073     * @param certificateStore provides the certificate chain for the keys
074     */
075    public GoogleCloudSigningService(String keyring, String token, Function<String, Certificate[]> certificateStore) {
076        this.keyring = keyring;
077        this.certificateStore = certificateStore;
078        this.client = new RESTClient("https://cloudkms.googleapis.com/v1/", conn -> conn.setRequestProperty("Authorization", "Bearer " + token));
079    }
080
081    @Override
082    public String getName() {
083        return "GoogleCloud";
084    }
085
086    @Override
087    public List<String> aliases() throws KeyStoreException {
088        List<String> aliases = new ArrayList<>();
089
090        try {
091            Map<String, ?> response = client.get(keyring + "/cryptoKeys");
092            Object[] cryptoKeys = (Object[]) response.get("cryptoKeys");
093            for (Object cryptoKey : cryptoKeys) {
094                String name = (String) ((Map) cryptoKey).get("name");
095                aliases.add(name.substring(name.lastIndexOf("/") + 1));
096            }
097        } catch (IOException e) {
098            throw new KeyStoreException(e);
099        }
100
101        return aliases;
102    }
103
104    @Override
105    public Certificate[] getCertificateChain(String alias) {
106        return certificateStore.apply(alias);
107    }
108
109    @Override
110    public SigningServicePrivateKey getPrivateKey(String alias) throws UnrecoverableKeyException {
111        // check if the alias is absolute or relative to the keyring
112        if (!alias.startsWith("projects/")) {
113            alias = keyring + "/cryptoKeys/" + alias;
114        }
115
116        if (keys.containsKey(alias)) {
117            return keys.get(alias);
118        }
119
120        String algorithm;
121
122        try {
123            if (alias.contains("cryptoKeyVersions")) {
124                // full key with version specified
125                if (alias.contains(":")) {
126                    // algorithm appended to the alias
127                    algorithm = alias.substring(alias.indexOf(':') + 1) + "_SIGN";
128                    alias = alias.substring(0, alias.indexOf(':'));
129                } else {
130                    Map<String, ?> response = client.get(alias);
131                    algorithm = (String) response.get("algorithm");
132                }
133            } else {
134                // key version not specified, find the most recent
135                Map<String, ?> response = client.get(alias + "/cryptoKeyVersions?filter=state%3DENABLED");
136                Object[] cryptoKeyVersions = (Object[]) response.get("cryptoKeyVersions");
137                if (cryptoKeyVersions == null || cryptoKeyVersions.length == 0) {
138                    throw new UnrecoverableKeyException("Unable to fetch Google Cloud private key '" + alias + "', no version found");
139                }
140
141                Map<String, ?> cryptoKeyVersion = (Map) cryptoKeyVersions[cryptoKeyVersions.length - 1];
142                alias = (String) cryptoKeyVersion.get("name");
143                algorithm = (String) cryptoKeyVersion.get("algorithm");
144            }
145        } catch (IOException e) {
146            throw (UnrecoverableKeyException) new UnrecoverableKeyException("Unable to fetch Google Cloud private key '" + alias + "'").initCause(e);
147        }
148
149        algorithm = algorithm.substring(0, algorithm.indexOf("_")); // RSA_SIGN_PKCS1_2048_SHA256 -> RSA
150
151        SigningServicePrivateKey key = new SigningServicePrivateKey(alias, algorithm);
152        keys.put(alias, key);
153        return key;
154    }
155
156    @Override
157    public byte[] sign(SigningServicePrivateKey privateKey, String algorithm, byte[] data) throws GeneralSecurityException {
158        DigestAlgorithm digestAlgorithm = DigestAlgorithm.of(algorithm.substring(0, algorithm.toLowerCase().indexOf("with")));
159        data = digestAlgorithm.getMessageDigest().digest(data);
160
161        Map<String, String> digest = new HashMap<>();
162        digest.put(digestAlgorithm.name().toLowerCase(), Base64.getEncoder().encodeToString(data));
163        Map<String, Object> request = new HashMap<>();
164        request.put("digest", digest);
165
166        try {
167            Map<String, Object> args = new HashMap<>();
168            args.put(JsonWriter.TYPE, "false");
169            Map<String, ?> response = client.post(privateKey.getId() + ":asymmetricSign", JsonWriter.objectToJson(request, args));
170            String signature = (String) response.get("signature");
171
172            return Base64.getDecoder().decode(signature);
173        } catch (IOException e) {
174            throw new GeneralSecurityException(e);
175        }
176    }
177}