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 net.jsign.DigestAlgorithm;
032
033/**
034 * Signing service using the Google Cloud Key Management API.
035 *
036 * <p>The key alias can take one of the following forms:</p>
037 *  <ul>
038 *   <li>The absolute path of the key with the exact version specified:
039 *       <tt>projects/first-rain-123/locations/global/keyRings/mykeyring/cryptoKeys/mykey/cryptoKeyVersions/2</tt></li>
040 *   <li>The absolute path of the key without the version specified, the first version enabled will be used:
041 *       <tt>projects/first-rain-123/locations/global/keyRings/mykeyring/cryptoKeys/mykey</tt></li>
042 *   <li>The path of the key relatively to the keyring with the version specified: <tt>mykey/cryptoKeyVersions/2</tt></li>
043 *   <li>The path of the key relatively to the keyring without the version specified: <tt>mykey</tt></li>
044 * </ul>
045 *
046 * @since 4.0
047 * @see <a href="https://cloud.google.com/kms/docs/reference/rest">Cloud Key Management Service (KMS) API</a>
048 */
049public class GoogleCloudSigningService implements SigningService {
050
051    /** The name of the keyring */
052    private final String keyring;
053
054    /** Source for the certificates */
055    private final Function<String, Certificate[]> certificateStore;
056
057    /** Cache of private keys indexed by id */
058    private final Map<String, SigningServicePrivateKey> keys = new HashMap<>();
059
060    private final RESTClient client;
061
062    /**
063     * Creates a new Google Cloud signing service.
064     *
065     * @param keyring          the path of the keyring (for example <tt>projects/first-rain-123/locations/global/keyRings/mykeyring</tt>)
066     * @param token            the Google Cloud API access token
067     * @param certificateStore provides the certificate chain for the keys
068     */
069    public GoogleCloudSigningService(String keyring, String token, Function<String, Certificate[]> certificateStore) {
070        this("https://cloudkms.googleapis.com/v1/", keyring, token, certificateStore);
071    }
072
073    GoogleCloudSigningService(String endpoint, String keyring, String token, Function<String, Certificate[]> certificateStore) {
074        this.keyring = keyring;
075        this.certificateStore = certificateStore;
076        this.client = new RESTClient(endpoint)
077                .authentication(conn -> conn.setRequestProperty("Authorization", "Bearer " + token))
078                .errorHandler(response -> {
079                    StringBuilder message = new StringBuilder();
080                    if (response.get("error") instanceof Map) {
081                        Map error = (Map) response.get("error");
082                        if (error.get("code") != null) {
083                            message.append(error.get("code"));
084                        }
085                        if (error.get("status") != null) {
086                            if (message.length() > 0) {
087                                message.append(" - ");
088                            }
089                            message.append(error.get("status"));
090                        }
091                        if (error.get("message") != null) {
092                            if (message.length() > 0) {
093                                message.append(": ");
094                            }
095                            message.append(error.get("message"));
096                        }
097                    }
098                    return message.toString();
099                });
100    }
101
102    @Override
103    public String getName() {
104        return "GoogleCloud";
105    }
106
107    @Override
108    public List<String> aliases() throws KeyStoreException {
109        List<String> aliases = new ArrayList<>();
110
111        try {
112            Map<String, ?> response = client.get(keyring + "/cryptoKeys");
113            Object[] cryptoKeys = (Object[]) response.get("cryptoKeys");
114            for (Object cryptoKey : cryptoKeys) {
115                String name = (String) ((Map) cryptoKey).get("name");
116                aliases.add(name.substring(name.lastIndexOf("/") + 1));
117            }
118        } catch (IOException e) {
119            throw new KeyStoreException(e);
120        }
121
122        return aliases;
123    }
124
125    @Override
126    public Certificate[] getCertificateChain(String alias) {
127        return certificateStore.apply(alias);
128    }
129
130    @Override
131    public SigningServicePrivateKey getPrivateKey(String alias, char[] password) throws UnrecoverableKeyException {
132        // check if the alias is absolute or relative to the keyring
133        if (!alias.startsWith("projects/")) {
134            alias = keyring + "/cryptoKeys/" + alias;
135        }
136
137        if (keys.containsKey(alias)) {
138            return keys.get(alias);
139        }
140
141        String algorithm;
142
143        try {
144            if (alias.contains("cryptoKeyVersions")) {
145                // full key with version specified
146                if (alias.contains(":")) {
147                    // syntax with the algorithm appended to the alias
148                    algorithm = alias.substring(alias.indexOf(':') + 1) + "_SIGN";
149                    alias = alias.substring(0, alias.indexOf(':'));
150                } else {
151                    Certificate[] chain = getCertificateChain(alias);
152                    if (chain != null && chain.length > 0) {
153                        Certificate certificate = chain[0];
154                        algorithm = certificate.getPublicKey().getAlgorithm() + "_SIGN";
155                    } else {
156                        Map<String, ?> response = client.get(alias);
157                        algorithm = (String) response.get("algorithm");
158                    }
159                }
160            } else {
161                // key version not specified, find the most recent
162                Map<String, ?> response = client.get(alias + "/cryptoKeyVersions?filter=state%3DENABLED");
163                Object[] cryptoKeyVersions = (Object[]) response.get("cryptoKeyVersions");
164                if (cryptoKeyVersions == null || cryptoKeyVersions.length == 0) {
165                    throw new UnrecoverableKeyException("Unable to fetch Google Cloud private key '" + alias + "', no version found");
166                }
167
168                Map<String, ?> cryptoKeyVersion = (Map) cryptoKeyVersions[cryptoKeyVersions.length - 1];
169                alias = (String) cryptoKeyVersion.get("name");
170                algorithm = (String) cryptoKeyVersion.get("algorithm");
171            }
172        } catch (IOException e) {
173            throw (UnrecoverableKeyException) new UnrecoverableKeyException("Unable to fetch Google Cloud private key '" + alias + "'").initCause(e);
174        }
175
176        algorithm = algorithm.substring(0, algorithm.indexOf("_")); // RSA_SIGN_PKCS1_2048_SHA256 -> RSA
177
178        SigningServicePrivateKey key = new SigningServicePrivateKey(alias, algorithm, this);
179        keys.put(alias, key);
180        keys.put(alias.substring(0, alias.indexOf("/cryptoKeyVersions")), key); // cache without the version
181        keys.put(alias + ":" + algorithm, key); // cache with the algorithm appended
182        return key;
183    }
184
185    @Override
186    public byte[] sign(SigningServicePrivateKey privateKey, String algorithm, byte[] data) throws GeneralSecurityException {
187        DigestAlgorithm digestAlgorithm = DigestAlgorithm.of(algorithm.substring(0, algorithm.toLowerCase().indexOf("with")));
188        data = digestAlgorithm.getMessageDigest().digest(data);
189
190        Map<String, String> digest = new HashMap<>();
191        digest.put(digestAlgorithm.name().toLowerCase(), Base64.getEncoder().encodeToString(data));
192        Map<String, Object> request = new HashMap<>();
193        request.put("digest", digest);
194
195        try {
196            Map<String, ?> response = client.post(privateKey.getId() + ":asymmetricSign", JsonWriter.format(request));
197            String signature = (String) response.get("signature");
198
199            return Base64.getDecoder().decode(signature);
200        } catch (IOException e) {
201            throw new GeneralSecurityException(e);
202        }
203    }
204}