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}