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}