001/* 002 * Copyright 2024 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.InetAddress; 022import java.net.UnknownHostException; 023import java.nio.charset.StandardCharsets; 024import java.security.GeneralSecurityException; 025import java.security.InvalidAlgorithmParameterException; 026import java.security.KeyStoreException; 027import java.security.PrivateKey; 028import java.security.Signature; 029import java.security.UnrecoverableKeyException; 030import java.security.cert.Certificate; 031import java.text.DateFormat; 032import java.text.SimpleDateFormat; 033import java.util.ArrayList; 034import java.util.Base64; 035import java.util.Date; 036import java.util.HashMap; 037import java.util.List; 038import java.util.Locale; 039import java.util.Map; 040import java.util.TimeZone; 041import java.util.function.Function; 042import java.util.regex.Matcher; 043import java.util.regex.Pattern; 044 045import net.jsign.DigestAlgorithm; 046 047/** 048 * Signing service using the Oracle Cloud API. 049 * 050 * @since 7.0 051 */ 052public class OracleCloudSigningService implements SigningService { 053 054 /** Source for the certificates */ 055 private final Function<String, Certificate[]> certificateStore; 056 057 /** The credentials */ 058 private final OracleCloudCredentials credentials; 059 060 /** Mapping between Java and OCI signing algorithms */ 061 private final Map<String, String> algorithmMapping = new HashMap<>(); 062 { 063 algorithmMapping.put("SHA256withRSA", "SHA_256_RSA_PKCS1_V1_5"); 064 algorithmMapping.put("SHA384withRSA", "SHA_384_RSA_PKCS1_V1_5"); 065 algorithmMapping.put("SHA512withRSA", "SHA_512_RSA_PKCS1_V1_5"); 066 algorithmMapping.put("SHA256withECDSA", "ECDSA_SHA_256"); 067 algorithmMapping.put("SHA384withECDSA", "ECDSA_SHA_384"); 068 algorithmMapping.put("SHA512withECDSA", "ECDSA_SHA_512"); 069 algorithmMapping.put("SHA256withRSA/PSS", "SHA_256_RSA_PKCS_PSS"); 070 algorithmMapping.put("SHA384withRSA/PSS", "SHA_394_RSA_PKCS_PSS"); 071 algorithmMapping.put("SHA512withRSA/PSS", "SHA_512_RSA_PKCS_PSS"); 072 } 073 074 /** 075 * Creates a new Oracle Cloud signing service. 076 * 077 * @param credentials the Oracle Cloud credentials (user, tenancy, region, private key) 078 * @param certificateStore provides the certificate chain for the keys 079 */ 080 public OracleCloudSigningService(OracleCloudCredentials credentials, Function<String, Certificate[]> certificateStore) { 081 this.credentials = credentials; 082 this.certificateStore = certificateStore; 083 } 084 085 @Override 086 public String getName() { 087 return "OracleCloud"; 088 } 089 090 String getVaultEndpoint() { 091 return "https://kms." + credentials.getRegion() + ".oraclecloud.com"; 092 } 093 094 @Override 095 public List<String> aliases() throws KeyStoreException { 096 List<String> aliases = new ArrayList<>(); 097 098 try { 099 // VaultSummary/ListVaults (https://docs.oracle.com/en-us/iaas/api/#/en/key/release/VaultSummary/ListVaults) 100 RESTClient kmsClient = new RESTClient(getVaultEndpoint()).authentication(this::sign).errorHandler(this::error); 101 Map<String, ?> result = kmsClient.get("/20180608/vaults?compartmentId=" + credentials.getTenancy()); 102 Object[] vaults = (Object[]) result.get("result"); 103 for (Object v : vaults) { 104 Map<String, ?> vault = (Map<String, ?>) v; 105 if ("ACTIVE".equals(vault.get("lifecycleState"))) { 106 String endpoint = (String) vault.get("managementEndpoint"); 107 RESTClient managementClient = new RESTClient(endpoint).authentication(this::sign).errorHandler(this::error); 108 109 // KeySummary/ListKeys (https://docs.oracle.com/en-us/iaas/api/#/en/key/release/KeySummary/ListKeys) 110 result = managementClient.get("/20180608/keys?compartmentId=" + credentials.getTenancy()); 111 Object[] keys = (Object[]) result.get("result"); 112 for (Object k : keys) { 113 Map<String, ?> key = (Map<String, ?>) k; 114 if ("ENABLED".equals(key.get("lifecycleState")) && !"EXTERNAL".equals(key.get("protectionMode"))) { 115 aliases.add((String) key.get("id")); 116 } 117 } 118 } 119 } 120 } catch (IOException e) { 121 throw new KeyStoreException(e); 122 } 123 124 return aliases; 125 } 126 127 @Override 128 public Certificate[] getCertificateChain(String alias) { 129 return certificateStore.apply(alias); 130 } 131 132 @Override 133 public SigningServicePrivateKey getPrivateKey(String alias, char[] password) throws UnrecoverableKeyException { 134 Certificate[] chain = getCertificateChain(alias); 135 String algorithm = chain[0].getPublicKey().getAlgorithm(); 136 137 return new SigningServicePrivateKey(alias, algorithm, this); 138 } 139 140 @Override 141 public byte[] sign(SigningServicePrivateKey privateKey, String algorithm, byte[] data) throws GeneralSecurityException { 142 String alg = algorithmMapping.get(algorithm); 143 if (alg == null) { 144 throw new InvalidAlgorithmParameterException("Unsupported signing algorithm: " + algorithm); 145 } 146 147 // SignedData/Sign (https://docs.oracle.com/en-us/iaas/api/#/en/key/release/SignedData/Sign) 148 Map<String, String> request = new HashMap<>(); 149 request.put("keyId", privateKey.getId()); 150 request.put("messageType", "RAW"); 151 request.put("message", Base64.getEncoder().encodeToString(data)); 152 request.put("signingAlgorithm", alg); 153 154 try { 155 RESTClient client = new RESTClient(getKeyEndpoint(privateKey.getId())).authentication(this::sign).errorHandler(this::error); 156 Map<String, ?> response = client.post("/20180608/sign", JsonWriter.format(request)); 157 String signature = (String) response.get("signature"); 158 return Base64.getDecoder().decode(signature); 159 } catch (IOException e) { 160 throw new GeneralSecurityException(e); 161 } 162 } 163 164 String getKeyEndpoint(String keyId) { 165 // extract the vault from the key id 166 Pattern pattern = Pattern.compile("ocid1\\.key\\.oc1\\.([^.]*)\\.([^.]*)\\..*"); 167 Matcher matcher = pattern.matcher(keyId); 168 if (!matcher.matches()) { 169 throw new IllegalArgumentException("Invalid key id: " + keyId); 170 } 171 String region = matcher.group(1); 172 String vaultId = matcher.group(2); 173 174 String hostname = vaultId + "-crypto.kms." + region + ".oci.oraclecloud.com"; 175 if (isUnknownHost(hostname)) { 176 hostname = vaultId + "-crypto.kms." + region + ".oraclecloud.com"; 177 } 178 179 return "https://" + hostname; 180 } 181 182 boolean isUnknownHost(String hostname) { 183 try { 184 InetAddress.getByName(hostname); 185 return false; 186 } catch (UnknownHostException uhe) { 187 return true; 188 } 189 } 190 191 /** 192 * Signs the request 193 * 194 * @see <a href="https://docs.oracle.com/en-us/iaas/Content/API/Concepts/signingrequests.htm">Request signatures</a> 195 * @see <a href="https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures-08">Signing HTTP Messages draft-cavage-http-signatures-08</a> 196 */ 197 private void sign(HttpURLConnection conn, byte[] data) { 198 StringBuilder signedHeaders = new StringBuilder(); 199 StringBuilder stringToSign = new StringBuilder(); 200 201 // date 202 DateFormat dateFormat = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss z", Locale.US); 203 dateFormat.setTimeZone(TimeZone.getTimeZone("GMT")); 204 String date = dateFormat.format(new Date()); 205 conn.setRequestProperty("Date", date); 206 addSignedHeader(signedHeaders, stringToSign, "date", date); 207 208 // request target 209 String query = conn.getURL().getPath() + (conn.getURL().getQuery() != null ? "?" + conn.getURL().getQuery() : ""); 210 addSignedHeader(signedHeaders, stringToSign, "(request-target)", conn.getRequestMethod().toLowerCase() + " " + query); 211 212 // host 213 addSignedHeader(signedHeaders, stringToSign, "host", conn.getURL().getHost()); 214 215 if (data != null) { 216 // content length 217 int contentLength = data.length; 218 conn.setRequestProperty("Content-Length", String.valueOf(contentLength)); 219 addSignedHeader(signedHeaders, stringToSign, "content-length", String.valueOf(contentLength)); 220 221 // content type 222 conn.setRequestProperty("Content-Type", "application/json"); 223 addSignedHeader(signedHeaders, stringToSign, "content-type", "application/json"); 224 225 // content sha256 226 String digest = Base64.getEncoder().encodeToString(DigestAlgorithm.SHA256.getMessageDigest().digest(data)); 227 conn.setRequestProperty("x-content-sha256", digest); 228 addSignedHeader(signedHeaders, stringToSign, "x-content-sha256", digest); 229 } 230 231 String signature = Base64.getEncoder().encodeToString(rsa256sign(credentials.getPrivateKey(), stringToSign.toString().trim())); 232 String authorization = String.format("Signature headers=\"%s\",keyId=\"%s\",algorithm=\"rsa-sha256\",signature=\"%s\",version=\"1\"", signedHeaders.toString().trim(), credentials.getKeyId(), signature); 233 conn.setRequestProperty("Authorization", authorization); 234 } 235 236 private void addSignedHeader(StringBuilder signedHeaders, StringBuilder stringToSign, String key, String value) { 237 signedHeaders.append(key).append(" "); 238 stringToSign.append(key).append(": ").append(value).append("\n"); 239 } 240 241 private byte[] rsa256sign(PrivateKey privateKey, String message) { 242 try { 243 Signature signature = Signature.getInstance("SHA256withRSA"); 244 signature.initSign(privateKey); 245 signature.update(message.getBytes(StandardCharsets.UTF_8)); 246 return signature.sign(); 247 } catch (GeneralSecurityException e) { 248 throw new RuntimeException(e); 249 } 250 } 251 252 private String error(Map<String, ?> response) { 253 return response.get("code") + ": " + response.get("message"); 254 } 255}