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}