001/*
002 * Copyright 2023 Maria Merkel
003 * Copyright 2024 Eatay Mizrachi
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017
018package net.jsign.jca;
019
020import java.io.IOException;
021import java.security.GeneralSecurityException;
022import java.security.KeyStoreException;
023import java.security.UnrecoverableKeyException;
024import java.security.cert.Certificate;
025import java.util.ArrayList;
026import java.util.Base64;
027import java.util.HashMap;
028import java.util.List;
029import java.util.Map;
030import java.util.function.Function;
031
032import net.jsign.DigestAlgorithm;
033
034/**
035 * Signing service using the HashiCorp Vault API. It supports the Google Cloud KMS and Transit secrets engines.
036 *
037 * @see <a href="https://developer.hashicorp.com/vault/api-docs/secret/gcpkms">HashiCorp Vault API - Google Cloud KMS Secrets Engine</a>
038 * @see <a href="https://developer.hashicorp.com/vault/api-docs/secret/transit">HashiCorp Vault API - Transit Secrets Engine</a>
039 * @since 5.0
040 */
041public class HashiCorpVaultSigningService implements SigningService {
042
043    private final Function<String, Certificate[]> certificateStore;
044
045    /** Cache of private keys indexed by id */
046    private final Map<String, SigningServicePrivateKey> keys = new HashMap<>();
047
048    private final RESTClient client;
049
050    /** The type of secret engine */
051    private VaultEngine engine;
052
053    private enum VaultEngine { GCPKMS, TRANSIT }
054
055    /**
056     * Creates a new HashiCorp Vault signing service.
057     *
058     * @param engineURL        the URL of the HashiCorp Vault secrets engine
059     * @param token            the HashiCorp Vault token
060     * @param certificateStore provides the certificate chain for the keys
061     */
062    public HashiCorpVaultSigningService(String engineURL, String token, Function<String, Certificate[]> certificateStore) {
063        this.certificateStore = certificateStore;
064        this.client = new RESTClient(engineURL.endsWith("/") ? engineURL : engineURL + "/")
065                .authentication(conn -> conn.setRequestProperty("Authorization", "Bearer " + token));
066    }
067
068    @Override
069    public String getName() {
070        return "HashiCorpVault";
071    }
072
073    /**
074     * Returns the list of key names available in the secrets engine.
075     *
076     * NOTE: This will return the key name only, not the key name and version.
077     * HashiCorp Vault does not provide a function to retrieve the key version.
078     * The key version will need to be appended to the key name when using the key.
079     *
080     * @return list of key names
081     */
082    @Override
083    public List<String> aliases() throws KeyStoreException {
084        List<String> aliases = new ArrayList<>();
085
086        try {
087            Map<String, ?> response = client.get("keys?list=true");
088            Object[] keys = ((Map<String, Object[]>) response.get("data")).get("keys");
089            for (Object key : keys) {
090                aliases.add((String) key);
091            }
092        } catch (IOException e) {
093            throw new KeyStoreException(e);
094        }
095
096        return aliases;
097    }
098
099    @Override
100    public Certificate[] getCertificateChain(String alias) throws KeyStoreException {
101        return certificateStore.apply(alias);
102    }
103
104    @Override
105    public SigningServicePrivateKey getPrivateKey(String alias, char[] password) throws UnrecoverableKeyException {
106        if (keys.containsKey(alias)) {
107            return keys.get(alias);
108        }
109
110        if (!alias.contains(":")) {
111            throw new UnrecoverableKeyException("Unable to fetch HashiCorp Vault private key '" + alias + "' (missing key version)");
112        }
113
114        String algorithm;
115
116        try {
117            Map<String, ?> response = client.get("keys/" + alias.substring(0, alias.indexOf(":")));
118            Map<String, String> data = (Map<String, String>) response.get("data");
119
120            if (data.containsKey("algorithm")) {
121                engine = VaultEngine.GCPKMS;
122                // GCPKMS key type format : 'rsa_sign_pkcs1_<BITS>_<DIGEST_ALGORITHM>'
123                algorithm = data.get("algorithm");
124                algorithm = algorithm.substring(0, algorithm.indexOf("_")).toUpperCase();
125            } else if (data.containsKey("type")) {
126                engine = VaultEngine.TRANSIT;
127                // Transit key type format : 'rsa-<BITS>'
128                algorithm = data.get("type");
129                algorithm = algorithm.substring(0, algorithm.indexOf("-")).toUpperCase();
130            } else {
131                throw new UnrecoverableKeyException("Unsupported secret engine");
132            }
133        } catch (IOException e) {
134            throw (UnrecoverableKeyException) new UnrecoverableKeyException("Unable to fetch HashiCorp Vault private key '" + alias + "'").initCause(e);
135        }
136
137        SigningServicePrivateKey key = new SigningServicePrivateKey(alias, algorithm, this);
138        keys.put(alias, key);
139        return key;
140    }
141
142    @Override
143    public byte[] sign(SigningServicePrivateKey privateKey, String algorithm, byte[] data) throws GeneralSecurityException {
144        DigestAlgorithm digestAlgorithm = DigestAlgorithm.of(algorithm.substring(0, algorithm.toLowerCase().indexOf("with")));
145        data = digestAlgorithm.getMessageDigest().digest(data);
146
147        String alias = privateKey.getId();
148        String keyName = alias.substring(0, alias.indexOf(":"));
149        String keyVersion = alias.substring(alias.indexOf(":") + 1);
150
151        Map<String, Object> request = new HashMap<>();
152        request.put("key_version", keyVersion);
153
154        if (engine == VaultEngine.GCPKMS) {
155            request.put("digest", Base64.getEncoder().encodeToString(data));
156        } else {
157            request.put("input", Base64.getEncoder().encodeToString(data));
158            request.put("prehashed", true);
159            request.put("hash_algorithm", getHashAlgorithm(digestAlgorithm));
160
161            if ("RSA".equals(privateKey.getAlgorithm())) {
162                // RSA signatures in HashiCorp Vault Transit use RSA-PSS by default
163                request.put("signature_algorithm", "pkcs1v15");
164            }
165        }
166
167        try {
168            Map<String, ?> response = client.post("sign/" + keyName, JsonWriter.format(request));
169
170            Map<String, String> response_data = (Map<String, String>) response.get("data");
171
172            String signature;
173            if (engine == VaultEngine.GCPKMS) {
174                // Google Cloud KMS signature format: '<BASE64>'
175                signature = response_data.get("signature");
176            } else {
177                // Transit signature format: 'vault:v<KEY_VERSION>:<BASE64>'
178                String[] fields = response_data.get("signature").split(":");
179                signature = fields[2];
180            }
181
182            return Base64.getDecoder().decode(signature);
183        } catch (IOException e) {
184            throw new GeneralSecurityException(e);
185        }
186    }
187
188    private String getHashAlgorithm(DigestAlgorithm digestAlgorithm) {
189        switch (digestAlgorithm) {
190            case SHA1:
191                return "sha1";
192            case SHA256:
193                return "sha2-256";
194            case SHA384:
195                return "sha2-384";
196            case SHA512:
197                return "sha2-512";
198            default:
199                throw new IllegalArgumentException("Unsupported digest algorithm: " + digestAlgorithm);
200        }
201    }
202}