001/**
002 * Copyright 2022 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.net.HttpURLConnection;
021import java.net.URL;
022import java.security.GeneralSecurityException;
023import java.security.InvalidAlgorithmParameterException;
024import java.security.KeyStoreException;
025import java.security.MessageDigest;
026import java.security.UnrecoverableKeyException;
027import java.security.cert.Certificate;
028import java.text.DateFormat;
029import java.text.SimpleDateFormat;
030import java.util.ArrayList;
031import java.util.Base64;
032import java.util.Collections;
033import java.util.Date;
034import java.util.HashMap;
035import java.util.List;
036import java.util.Map;
037import java.util.TimeZone;
038import java.util.TreeMap;
039import java.util.function.Function;
040import java.util.regex.Matcher;
041import java.util.regex.Pattern;
042import java.util.stream.Collectors;
043import javax.crypto.Mac;
044import javax.crypto.spec.SecretKeySpec;
045
046import com.cedarsoftware.util.io.JsonWriter;
047import org.apache.commons.codec.binary.Hex;
048
049import net.jsign.DigestAlgorithm;
050
051import static java.nio.charset.StandardCharsets.*;
052
053/**
054 * Signing service using the AWS API.
055 *
056 * @since 5.0
057 * @see <a href="https://docs.aws.amazon.com/kms/latest/APIReference/">AWS Key Management Service API Reference</a>
058 * @see <a href="https://docs.aws.amazon.com/general/latest/gr/signing_aws_api_requests.html">Signing AWS API Requests</a>
059 */
060public class AmazonSigningService implements SigningService {
061
062    /** Source for the certificates */
063    private final Function<String, Certificate[]> certificateStore;
064
065    /** Cache of private keys indexed by id */
066    private final Map<String, SigningServicePrivateKey> keys = new HashMap<>();
067
068    private final RESTClient client;
069
070    /** Mapping between Java and AWS signing algorithms */
071    private final Map<String, String> algorithmMapping = new HashMap<>();
072    {
073        algorithmMapping.put("SHA256withRSA", "RSASSA_PKCS1_V1_5_SHA_256");
074        algorithmMapping.put("SHA384withRSA", "RSASSA_PKCS1_V1_5_SHA_384");
075        algorithmMapping.put("SHA512withRSA", "RSASSA_PKCS1_V1_5_SHA_512");
076        algorithmMapping.put("SHA256withECDSA", "ECDSA_SHA_256");
077        algorithmMapping.put("SHA384withECDSA", "ECDSA_SHA_384");
078        algorithmMapping.put("SHA512withECDSA", "ECDSA_SHA_512");
079        algorithmMapping.put("SHA256withRSA/PSS", "RSASSA_PSS_SHA_256");
080        algorithmMapping.put("SHA384withRSA/PSS", "RSASSA_PSS_SHA_384");
081        algorithmMapping.put("SHA512withRSA/PSS", "RSASSA_PSS_SHA_512");
082    }
083
084    /**
085     * Creates a new AWS signing service.
086     *
087     * @param region           the AWS region holding the keys (for example <tt>eu-west-3</tt>)
088     * @param credentials      the AWS credentials
089     * @param certificateStore provides the certificate chain for the keys
090     */
091    public AmazonSigningService(String region, AmazonCredentials credentials, Function<String, Certificate[]> certificateStore) {
092        this.certificateStore = certificateStore;
093        this.client = new RESTClient("https://kms." + region + ".amazonaws.com", (conn, data) -> sign(conn, credentials, data, null));
094    }
095
096    /**
097     * Creates a new AWS signing service.
098     *
099     * @param region           the AWS region holding the keys (for example <tt>eu-west-3</tt>)
100     * @param credentials      the AWS credentials: <tt>accessKey|secretKey|sessionToken</tt> (the session token is optional)
101     * @param certificateStore provides the certificate chain for the keys
102     */
103    @Deprecated
104    public AmazonSigningService(String region, String credentials, Function<String, Certificate[]> certificateStore) {
105        this(region, AmazonCredentials.parse(credentials), certificateStore);
106    }
107
108    @Override
109    public String getName() {
110        return "AWS";
111    }
112
113    @Override
114    public List<String> aliases() throws KeyStoreException {
115        List<String> aliases = new ArrayList<>();
116
117        try {
118            // kms:ListKeys (https://docs.aws.amazon.com/kms/latest/APIReference/API_ListKeys.html)
119            Map<String, ?> response = query("TrentService.ListKeys", "{}");
120            Object[] keys = (Object[]) response.get("Keys");
121            for (Object key : keys) {
122                aliases.add((String) ((Map) key).get("KeyId"));
123            }
124        } catch (IOException e) {
125            throw new KeyStoreException(e);
126        }
127
128        return aliases;
129    }
130
131    @Override
132    public Certificate[] getCertificateChain(String alias) throws KeyStoreException {
133        return certificateStore.apply(alias);
134    }
135
136    @Override
137    public SigningServicePrivateKey getPrivateKey(String alias, char[] password) throws UnrecoverableKeyException {
138        if (keys.containsKey(alias)) {
139            return keys.get(alias);
140        }
141
142        String algorithm;
143
144        try {
145            // kms:DescribeKey (https://docs.aws.amazon.com/kms/latest/APIReference/API_DescribeKey.html)
146            Map<String, ?> response = query("TrentService.DescribeKey", "{\"KeyId\":\"" + normalizeKeyId(alias) + "\"}");
147            Map<String, ?> keyMetadata = (Map<String, ?>) response.get("KeyMetadata");
148
149            String keyUsage = (String) keyMetadata.get("KeyUsage");
150            if (!"SIGN_VERIFY".equals(keyUsage)) {
151                throw new UnrecoverableKeyException("The key '" + alias + "' is not a signing key");
152            }
153
154            String keyState = (String) keyMetadata.get("KeyState");
155            if (!"Enabled".equals(keyState)) {
156                throw new UnrecoverableKeyException("The key '" + alias + "' is not enabled (" + keyState + ")");
157            }
158
159            String keySpec = (String) keyMetadata.get("KeySpec");
160            algorithm = keySpec.substring(0, keySpec.indexOf('_'));
161            if ("ECC".equals(algorithm)) {
162                algorithm = "EC";
163            }
164        } catch (IOException e) {
165            throw (UnrecoverableKeyException) new UnrecoverableKeyException("Unable to fetch AWS key '" + alias + "'").initCause(e);
166        }
167
168        SigningServicePrivateKey key = new SigningServicePrivateKey(alias, algorithm);
169        keys.put(alias, key);
170        return key;
171    }
172
173    @Override
174    public byte[] sign(SigningServicePrivateKey privateKey, String algorithm, byte[] data) throws GeneralSecurityException {
175        String alg = algorithmMapping.get(algorithm);
176        if (alg == null) {
177            throw new InvalidAlgorithmParameterException("Unsupported signing algorithm: " + algorithm);
178        }
179
180        DigestAlgorithm digestAlgorithm = DigestAlgorithm.of(algorithm.substring(0, algorithm.toLowerCase().indexOf("with")));
181        data = digestAlgorithm.getMessageDigest().digest(data);
182
183        // kms:Sign (https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html)
184        Map<String, String> request = new HashMap<>();
185        request.put("KeyId", normalizeKeyId(privateKey.getId()));
186        request.put("MessageType", "DIGEST");
187        request.put("Message", Base64.getEncoder().encodeToString(data));
188        request.put("SigningAlgorithm", alg);
189        request.put(JsonWriter.TYPE, "false");
190
191        try {
192            Map<String, ?> response = query("TrentService.Sign", JsonWriter.objectToJson(request));
193            String signature = (String) response.get("Signature");
194            return Base64.getDecoder().decode(signature);
195        } catch (IOException e) {
196            throw new GeneralSecurityException(e);
197        }
198    }
199
200    /**
201     * Sends a request to the AWS API.
202     */
203    private Map<String, ?> query(String target, String body) throws IOException {
204        Map<String, String> headers = new HashMap<>();
205        headers.put("X-Amz-Target", target);
206        headers.put("Content-Type", "application/x-amz-json-1.1");
207        return client.post("/", body, headers);
208    }
209
210    /**
211     * Prefixes the key id with <tt>alias/</tt> if necessary.
212     */
213    private String normalizeKeyId(String keyId) {
214        if (keyId.startsWith("arn:") || keyId.startsWith("alias/")) {
215            return keyId;
216        }
217
218        if (!keyId.matches("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")) {
219            return "alias/" + keyId;
220        } else {
221            return keyId;
222        }
223    }
224
225    /**
226     * Signs the request
227     *
228     * @see <a href="https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html">Signature Version 4 signing process</a>
229     */
230    void sign(HttpURLConnection conn, AmazonCredentials credentials, byte[] content, Date date) {
231        DateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
232        dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
233        DateFormat dateTimeFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
234        dateTimeFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
235        if (date == null) {
236            date = new Date();
237        }
238
239        // Extract the service name and the region from the endpoint
240        URL endpoint = conn.getURL();
241        Pattern hostnamePattern = Pattern.compile("^([^.]+)\\.([^.]+)\\.amazonaws\\.com$");
242        String host = endpoint.getHost();
243        Matcher matcher = hostnamePattern.matcher(host);
244        String regionName = matcher.matches() ? matcher.group(2) : "us-east-1";
245        String serviceName = matcher.matches() ? matcher.group(1) : host.substring(0, host.indexOf('.'));
246
247        String credentialScope = dateFormat.format(date) + "/" + regionName + "/" + serviceName + "/" + "aws4_request";
248
249        conn.addRequestProperty("X-Amz-Date", dateTimeFormat.format(date));
250
251        // Create the canonical request (https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html)
252        Map<String, List<String>> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
253        headers.putAll(conn.getRequestProperties());
254        headers.put("Host", Collections.singletonList(host));
255
256        String canonicalRequest = conn.getRequestMethod() + "\n"
257                + endpoint.getPath() + (endpoint.getPath().endsWith("/") ? "" : "/") + "\n"
258                + /* canonical query string, not used for kms operations */ "\n"
259                + canonicalHeaders(headers) + "\n"
260                + signedHeaders(headers) + "\n"
261                + sha256(content);
262
263        // Create the string to sign (https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html)
264        String stringToSign = "AWS4-HMAC-SHA256" + "\n"
265                + dateTimeFormat.format(date) + "\n"
266                + credentialScope + "\n"
267                + sha256(canonicalRequest.getBytes(UTF_8));
268
269        // Derive the signing key (https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html)
270        byte[] key = ("AWS4" + credentials.getSecretKey()).getBytes(UTF_8);
271        byte[] signingKey = hmac("aws4_request", hmac(serviceName, hmac(regionName, hmac(dateFormat.format(date), key))));
272
273        // Compute the signature (https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html)
274        byte[] signature = hmac(stringToSign, signingKey);
275
276        conn.setRequestProperty("Authorization",
277                "AWS4-HMAC-SHA256 Credential=" + credentials.getAccessKey() + "/" + credentialScope
278                + ", SignedHeaders=" + signedHeaders(headers)
279                + ", Signature=" + Hex.encodeHexString(signature).toLowerCase());
280
281        if (credentials.getSessionToken() != null) {
282            conn.setRequestProperty("X-Amz-Security-Token", credentials.getSessionToken());
283        }
284    }
285
286    private String canonicalHeaders(Map<String, List<String>> headers) {
287        return headers.entrySet().stream()
288                .map(entry -> entry.getKey().toLowerCase() + ":" + String.join(",", entry.getValue()).replaceAll("\\s+", " "))
289                .collect(Collectors.joining("\n")) + "\n";
290    }
291
292    private String signedHeaders(Map<String, List<String>> headers) {
293        return headers.keySet().stream()
294                .map(String::toLowerCase)
295                .collect(Collectors.joining(";"));
296    }
297
298    private byte[] hmac(String data, byte[] key) {
299        return hmac(data.getBytes(UTF_8), key);
300    }
301
302    private byte[] hmac(byte[] data, byte[] key) {
303        try {
304            Mac mac = Mac.getInstance("HmacSHA256");
305            mac.init(new SecretKeySpec(key, mac.getAlgorithm()));
306            return mac.doFinal(data);
307        } catch (Exception e) {
308            throw new RuntimeException(e);
309        }
310    }
311
312    private String sha256(byte[] data) {
313        MessageDigest digest =  DigestAlgorithm.SHA256.getMessageDigest();
314        digest.update(data);
315        return Hex.encodeHexString(digest.digest()).toLowerCase();
316    }
317}