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.function.Supplier;
041import java.util.regex.Matcher;
042import java.util.regex.Pattern;
043import java.util.stream.Collectors;
044import javax.crypto.Mac;
045import javax.crypto.spec.SecretKeySpec;
046
047import org.bouncycastle.util.encoders.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 provider
089     * @param certificateStore provides the certificate chain for the keys
090     * @since 6.0
091     */
092    public AmazonSigningService(String region, Supplier<AmazonCredentials> credentials, Function<String, Certificate[]> certificateStore) {
093        this(credentials, certificateStore, getEndpointUrl(region));
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
101     * @param certificateStore provides the certificate chain for the keys
102     */
103    public AmazonSigningService(String region, AmazonCredentials credentials, Function<String, Certificate[]> certificateStore) {
104        this(region, () -> credentials, certificateStore);
105    }
106
107    AmazonSigningService(Supplier<AmazonCredentials> credentials, Function<String, Certificate[]> certificateStore, String endpoint) {
108        this.certificateStore = certificateStore;
109        this.client = new RESTClient(endpoint)
110                .authentication((conn, data) -> sign(conn, credentials.get(), data, null))
111                .errorHandler(response -> response.get("__type") + ": " + response.get("message"));
112    }
113
114    /**
115     * Creates a new AWS signing service.
116     *
117     * @param region           the AWS region holding the keys (for example <tt>eu-west-3</tt>)
118     * @param credentials      the AWS credentials: <tt>accessKey|secretKey|sessionToken</tt> (the session token is optional)
119     * @param certificateStore provides the certificate chain for the keys
120     */
121    @Deprecated
122    public AmazonSigningService(String region, String credentials, Function<String, Certificate[]> certificateStore) {
123        this(region, AmazonCredentials.parse(credentials), certificateStore);
124    }
125
126    /**
127     * Returns the endpoint URL for the given AWS region.
128     *
129     * @param region the AWS region
130     * @return the endpoint URL
131     */
132    static String getEndpointUrl(String region) {
133        String domain = "true".equalsIgnoreCase(getenv("AWS_USE_FIPS_ENDPOINT")) ? "kms-fips" : "kms";
134        return "https://" + domain + "." + region + ".amazonaws.com";
135    }
136
137    @Override
138    public String getName() {
139        return "AWS";
140    }
141
142    @Override
143    public List<String> aliases() throws KeyStoreException {
144        List<String> aliases = new ArrayList<>();
145
146        try {
147            // kms:ListKeys (https://docs.aws.amazon.com/kms/latest/APIReference/API_ListKeys.html)
148            Map<String, ?> response = query("TrentService.ListKeys", "{}");
149            Object[] keys = (Object[]) response.get("Keys");
150            for (Object key : keys) {
151                aliases.add((String) ((Map) key).get("KeyId"));
152            }
153        } catch (IOException e) {
154            throw new KeyStoreException(e);
155        }
156
157        return aliases;
158    }
159
160    @Override
161    public Certificate[] getCertificateChain(String alias) throws KeyStoreException {
162        return certificateStore.apply(alias);
163    }
164
165    @Override
166    public SigningServicePrivateKey getPrivateKey(String alias, char[] password) throws UnrecoverableKeyException {
167        if (keys.containsKey(alias)) {
168            return keys.get(alias);
169        }
170
171        String algorithm;
172
173        try {
174            // kms:DescribeKey (https://docs.aws.amazon.com/kms/latest/APIReference/API_DescribeKey.html)
175            Map<String, ?> response = query("TrentService.DescribeKey", "{\"KeyId\":\"" + normalizeKeyId(alias) + "\"}");
176            Map<String, ?> keyMetadata = (Map<String, ?>) response.get("KeyMetadata");
177
178            String keyUsage = (String) keyMetadata.get("KeyUsage");
179            if (!"SIGN_VERIFY".equals(keyUsage)) {
180                throw new UnrecoverableKeyException("The key '" + alias + "' is not a signing key");
181            }
182
183            String keyState = (String) keyMetadata.get("KeyState");
184            if (!"Enabled".equals(keyState)) {
185                throw new UnrecoverableKeyException("The key '" + alias + "' is not enabled (" + keyState + ")");
186            }
187
188            String keySpec = (String) keyMetadata.get("KeySpec");
189            algorithm = keySpec.substring(0, keySpec.indexOf('_'));
190            if ("ECC".equals(algorithm)) {
191                algorithm = "EC";
192            }
193        } catch (IOException e) {
194            throw (UnrecoverableKeyException) new UnrecoverableKeyException("Unable to fetch AWS key '" + alias + "'").initCause(e);
195        }
196
197        SigningServicePrivateKey key = new SigningServicePrivateKey(alias, algorithm, this);
198        keys.put(alias, key);
199        return key;
200    }
201
202    @Override
203    public byte[] sign(SigningServicePrivateKey privateKey, String algorithm, byte[] data) throws GeneralSecurityException {
204        String alg = algorithmMapping.get(algorithm);
205        if (alg == null) {
206            throw new InvalidAlgorithmParameterException("Unsupported signing algorithm: " + algorithm);
207        }
208
209        DigestAlgorithm digestAlgorithm = DigestAlgorithm.of(algorithm.substring(0, algorithm.toLowerCase().indexOf("with")));
210        data = digestAlgorithm.getMessageDigest().digest(data);
211
212        // kms:Sign (https://docs.aws.amazon.com/kms/latest/APIReference/API_Sign.html)
213        Map<String, String> request = new HashMap<>();
214        request.put("KeyId", normalizeKeyId(privateKey.getId()));
215        request.put("MessageType", "DIGEST");
216        request.put("Message", Base64.getEncoder().encodeToString(data));
217        request.put("SigningAlgorithm", alg);
218
219        try {
220            Map<String, ?> response = query("TrentService.Sign", JsonWriter.format(request));
221            String signature = (String) response.get("Signature");
222            return Base64.getDecoder().decode(signature);
223        } catch (IOException e) {
224            throw new GeneralSecurityException(e);
225        }
226    }
227
228    /**
229     * Sends a request to the AWS API.
230     */
231    private Map<String, ?> query(String target, String body) throws IOException {
232        Map<String, String> headers = new HashMap<>();
233        headers.put("X-Amz-Target", target);
234        headers.put("Content-Type", "application/x-amz-json-1.1");
235        return client.post("/", body, headers);
236    }
237
238    /**
239     * Prefixes the key id with <tt>alias/</tt> if necessary.
240     */
241    private String normalizeKeyId(String keyId) {
242        if (keyId.startsWith("arn:") || keyId.startsWith("alias/")) {
243            return keyId;
244        }
245
246        if (!keyId.matches("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")) {
247            return "alias/" + keyId;
248        } else {
249            return keyId;
250        }
251    }
252
253    /**
254     * Signs the request
255     *
256     * @see <a href="https://docs.aws.amazon.com/general/latest/gr/signature-version-4.html">Signature Version 4 signing process</a>
257     */
258    void sign(HttpURLConnection conn, AmazonCredentials credentials, byte[] content, Date date) {
259        DateFormat dateFormat = new SimpleDateFormat("yyyyMMdd");
260        dateFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
261        DateFormat dateTimeFormat = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
262        dateTimeFormat.setTimeZone(TimeZone.getTimeZone("UTC"));
263        if (date == null) {
264            date = new Date();
265        }
266
267        // Extract the service name and the region from the endpoint
268        URL endpoint = conn.getURL();
269        Pattern hostnamePattern = Pattern.compile("^([^.]+)\\.([^.]+)\\.amazonaws\\.com$");
270        String host = endpoint.getHost();
271        Matcher matcher = hostnamePattern.matcher(host);
272        String regionName = matcher.matches() ? matcher.group(2) : "us-east-1";
273        String serviceName = matcher.matches() ? matcher.group(1).replace("-fips", "") : "kms";
274
275        String credentialScope = dateFormat.format(date) + "/" + regionName + "/" + serviceName + "/" + "aws4_request";
276
277        conn.addRequestProperty("X-Amz-Date", dateTimeFormat.format(date));
278
279        // Create the canonical request (https://docs.aws.amazon.com/general/latest/gr/sigv4-create-canonical-request.html)
280        Map<String, List<String>> headers = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
281        headers.putAll(conn.getRequestProperties());
282        headers.put("Host", Collections.singletonList(host));
283
284        String canonicalRequest = conn.getRequestMethod() + "\n"
285                + endpoint.getPath() + (endpoint.getPath().endsWith("/") ? "" : "/") + "\n"
286                + /* canonical query string, not used for kms operations */ "\n"
287                + canonicalHeaders(headers) + "\n"
288                + signedHeaders(headers) + "\n"
289                + sha256(content);
290
291        // Create the string to sign (https://docs.aws.amazon.com/general/latest/gr/sigv4-create-string-to-sign.html)
292        String stringToSign = "AWS4-HMAC-SHA256" + "\n"
293                + dateTimeFormat.format(date) + "\n"
294                + credentialScope + "\n"
295                + sha256(canonicalRequest.getBytes(UTF_8));
296
297        // Derive the signing key (https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html)
298        byte[] key = ("AWS4" + credentials.getSecretKey()).getBytes(UTF_8);
299        byte[] signingKey = hmac("aws4_request", hmac(serviceName, hmac(regionName, hmac(dateFormat.format(date), key))));
300
301        // Compute the signature (https://docs.aws.amazon.com/general/latest/gr/sigv4-calculate-signature.html)
302        byte[] signature = hmac(stringToSign, signingKey);
303
304        conn.setRequestProperty("Authorization",
305                "AWS4-HMAC-SHA256 Credential=" + credentials.getAccessKey() + "/" + credentialScope
306                + ", SignedHeaders=" + signedHeaders(headers)
307                + ", Signature=" + Hex.toHexString(signature).toLowerCase());
308
309        if (credentials.getSessionToken() != null) {
310            conn.setRequestProperty("X-Amz-Security-Token", credentials.getSessionToken());
311        }
312    }
313
314    private String canonicalHeaders(Map<String, List<String>> headers) {
315        return headers.entrySet().stream()
316                .map(entry -> entry.getKey().toLowerCase() + ":" + String.join(",", entry.getValue()).replaceAll("\\s+", " "))
317                .collect(Collectors.joining("\n")) + "\n";
318    }
319
320    private String signedHeaders(Map<String, List<String>> headers) {
321        return headers.keySet().stream()
322                .map(String::toLowerCase)
323                .collect(Collectors.joining(";"));
324    }
325
326    private byte[] hmac(String data, byte[] key) {
327        return hmac(data.getBytes(UTF_8), key);
328    }
329
330    private byte[] hmac(byte[] data, byte[] key) {
331        try {
332            Mac mac = Mac.getInstance("HmacSHA256");
333            mac.init(new SecretKeySpec(key, mac.getAlgorithm()));
334            return mac.doFinal(data);
335        } catch (Exception e) {
336            throw new RuntimeException(e);
337        }
338    }
339
340    private String sha256(byte[] data) {
341        MessageDigest digest =  DigestAlgorithm.SHA256.getMessageDigest();
342        digest.update(data);
343        return Hex.toHexString(digest.digest()).toLowerCase();
344    }
345
346    static String getenv(String name) {
347        return System.getenv(name);
348    }
349}