AmazonSigningServiceTest.java

/*
 * Copyright 2022 Emmanuel Bourg
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package net.jsign.jca;

import java.io.FileInputStream;
import java.io.FileReader;
import java.io.IOException;
import java.lang.reflect.AccessibleObject;
import java.lang.reflect.Field;
import java.net.HttpURLConnection;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.KeyStoreException;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.util.Arrays;
import java.util.Base64;
import java.util.Collection;
import java.util.Date;
import java.util.List;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.MockedStatic;

import static net.jadler.Jadler.*;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

public class AmazonSigningServiceTest {

    @Before
    public void setUp() {
        initJadler().withDefaultResponseStatus(404);
    }

    @After
    public void tearDown() {
        closeJadler();
    }

    private SigningService getTestService() {
        AmazonCredentials credentials = new AmazonCredentials("accessKey", "secretKey", null);
        return new AmazonSigningService(() -> credentials, alias -> {
            try (FileInputStream in = new FileInputStream("target/test-classes/keystores/jsign-test-certificate-full-chain.pem")) {
                CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
                Collection<? extends Certificate> certificates = certificateFactory.generateCertificates(in);
                return certificates.toArray(new Certificate[0]);
            } catch (IOException | CertificateException e) {
                throw new RuntimeException("Failed to load the certificate", e);
            }
        }, "http://localhost:" + port());
    }

    @Test
    public void testGetAliases() throws Exception {
        onRequest()
                .havingMethodEqualTo("POST")
                .havingPathEqualTo("/")
                .havingHeaderEqualTo("X-Amz-Target", "TrentService.ListKeys")
                .respond()
                .withStatus(200)
                .withContentType("application/x-amz-json-1.1")
                .withBody(new FileReader("target/test-classes/services/aws-listkeys.json"));

        SigningService service = getTestService();
        List<String> aliases = service.aliases();

        assertEquals("aliases", Arrays.asList("2d9ca5b0-6d51-4727-9dfc-186e62e4c5e2", "935ecb66-5c06-495b-babe-5798b1c0e1a8"), aliases);
    }

    @Test
    public void testGetAliasesWithError() {
        onRequest()
                .havingMethodEqualTo("POST")
                .havingPathEqualTo("/")
                .havingHeaderEqualTo("X-Amz-Target", "TrentService.ListKeys")
                .respond()
                .withStatus(400)
                .withContentType("application/x-amz-json-1.1")
                .withBody("{\"__type\":\"UnrecognizedClientException\",\"message\":\"The security token included in the request is invalid.\"}");

        SigningService service = getTestService();

        Exception e = assertThrows(KeyStoreException.class, service::aliases);
        assertEquals("message", "UnrecognizedClientException: The security token included in the request is invalid.", e.getCause().getMessage());
    }

    @Test
    public void testGetCertificateChain() throws Exception {
        SigningService service = getTestService();
        Certificate[] chain = service.getCertificateChain("key1");
        assertNotNull("chain", chain);
        assertEquals("number of certificates", 3, chain.length);
    }

    @Test
    public void testGetPrivateKeyRSA() throws Exception {
        onRequest()
                .havingMethodEqualTo("POST")
                .havingPathEqualTo("/")
                .havingHeaderEqualTo("X-Amz-Target", "TrentService.DescribeKey")
                .respond()
                .withStatus(200)
                .withContentType("application/json")
                .withBody(new FileReader("target/test-classes/services/aws-describekey-rsa.json"));

        SigningService service = getTestService();

        SigningServicePrivateKey key = service.getPrivateKey("jsign-rsa-2048", null);
        assertNotNull("null key", key);
        assertEquals("id", "jsign-rsa-2048", key.getId());
        assertEquals("algorithm", "RSA", key.getAlgorithm());

        // check if the key is cached
        SigningServicePrivateKey key2 = service.getPrivateKey("jsign-rsa-2048", null);
        assertSame("private key not cached", key, key2);
    }

    @Test
    public void testGetPrivateKeyEC() throws Exception {
        onRequest()
                .havingMethodEqualTo("POST")
                .havingPathEqualTo("/")
                .havingHeaderEqualTo("X-Amz-Target", "TrentService.DescribeKey")
                .respond()
                .withStatus(200)
                .withContentType("application/json")
                .withBody(new FileReader("target/test-classes/services/aws-describekey-ec.json"));

        SigningService service = getTestService();

        SigningServicePrivateKey key = service.getPrivateKey("jsign-ec-384", null);
        assertNotNull("null key", key);
        assertEquals("id", "jsign-ec-384", key.getId());
        assertEquals("algorithm", "EC", key.getAlgorithm());

        // check if the key is cached
        SigningServicePrivateKey key2 = service.getPrivateKey("jsign-ec-384", null);
        assertSame("private key not cached", key, key2);
    }

    @Test
    public void testGetPrivateKeyDisabled() throws Exception {
        onRequest()
                .havingMethodEqualTo("POST")
                .havingPathEqualTo("/")
                .havingHeaderEqualTo("X-Amz-Target", "TrentService.DescribeKey")
                .respond()
                .withStatus(200)
                .withContentType("application/json")
                .withBody(new FileReader("target/test-classes/services/aws-describekey-disabled.json"));

        SigningService service = getTestService();

        Exception e = assertThrows(UnrecoverableKeyException.class, () -> service.getPrivateKey("jsign-rsa-2048", null));
        assertEquals("message", "The key 'jsign-rsa-2048' is not enabled (PendingImport)", e.getMessage());
    }

    @Test
    public void testGetPrivateKeyWithWrongUsage() throws Exception {
        onRequest()
                .havingMethodEqualTo("POST")
                .havingPathEqualTo("/")
                .havingHeaderEqualTo("X-Amz-Target", "TrentService.DescribeKey")
                .respond()
                .withStatus(200)
                .withContentType("application/json")
                .withBody(new FileReader("target/test-classes/services/aws-describekey-encrypt.json"));

        SigningService service = getTestService();

        Exception e = assertThrows(UnrecoverableKeyException.class, () -> service.getPrivateKey("jsign-rsa-2048", null));
        assertEquals("message", "The key 'jsign-rsa-2048' is not a signing key", e.getMessage());
    }

    @Test
    public void testGetPrivateKeyWithError() {
        onRequest()
                .havingMethodEqualTo("POST")
                .havingPathEqualTo("/")
                .havingHeaderEqualTo("X-Amz-Target", "TrentService.DescribeKey")
                .respond()
                .withStatus(400)
                .withContentType("application/json")
                .withBody("{\"__type\":\"NotFoundException\",\"message\":\"Alias arn:aws:kms:eu-west-3:829022948260:alias/jsign-rsa-2048 is not found.\"}");

        SigningService service = getTestService();

        Exception e = assertThrows(UnrecoverableKeyException.class, () -> service.getPrivateKey("jsign-rsa-2048", null));
        assertEquals("message", "NotFoundException: Alias arn:aws:kms:eu-west-3:829022948260:alias/jsign-rsa-2048 is not found.", e.getCause().getMessage());
    }

    @Test
    public void testSign() throws Exception {
        onRequest()
                .havingMethodEqualTo("POST")
                .havingPathEqualTo("/")
                .havingHeaderEqualTo("X-Amz-Target", "TrentService.DescribeKey")
                .respond()
                .withStatus(200)
                .withContentType("application/json")
                .withBody(new FileReader("target/test-classes/services/aws-describekey-rsa.json"));
        onRequest()
                .havingMethodEqualTo("POST")
                .havingPathEqualTo("/")
                .havingHeaderEqualTo("X-Amz-Target", "TrentService.Sign")
                .respond()
                .withStatus(200)
                .withContentType("application/json")
                .withBody(new FileReader("target/test-classes/services/aws-sign.json"));

        SigningService service = getTestService();
        SigningServicePrivateKey privateKey = service.getPrivateKey("jsign-rsa-2048", null);
        String signature = Base64.getEncoder().encodeToString(service.sign(privateKey, "SHA256withRSA", "Hello".getBytes()));
        assertEquals("signature", "MiZ/YXfluqyuMfR3cnChG7+K7JmU2b8SzBAc6+WOpWQwIV4GfkLcRe0A68H45Lf+XPiMPPLrs7EqOv1EAnkYDFx5AqZBTWBfoaBeqKpy30OBvNbxIsaTLsaJYGypwmHOUTP+Djz7FxQUyM0uWVfUnHUDT564gQLz0cta6PKE/oMUo9fZhpv5VQcgfrbdUlPaD/cSAOb833ZSRzPWbnqztWO6py5sUugvqGFHKhsEXesx5yrPvJTKu5HVF3QM3E8YrgnVfFK14W8oyTJmXIWQxfYpwm/CW037UmolDMqwc3mjx1758kR+9lOcf8c/LSmD/SVD18SDSK4FyLQWOmn16A==", signature);
    }

    @Test
    public void testSignWithInvalidAlgorithm() throws Exception {
        onRequest()
                .havingMethodEqualTo("POST")
                .havingPathEqualTo("/")
                .havingHeaderEqualTo("X-Amz-Target", "TrentService.DescribeKey")
                .respond()
                .withStatus(200)
                .withContentType("application/json")
                .withBody(new FileReader("target/test-classes/services/aws-describekey-rsa.json"));

        SigningService service = getTestService();
        SigningServicePrivateKey privateKey = service.getPrivateKey("jsign-rsa-2048", null);

        Exception e = assertThrows(GeneralSecurityException.class, () -> service.sign(privateKey, "SHA1withRSA", "Hello".getBytes()));
        assertEquals("message", "Unsupported signing algorithm: SHA1withRSA", e.getMessage());
    }

    @Test
    public void testSignWithError() throws Exception {
        onRequest()
                .havingMethodEqualTo("POST")
                .havingPathEqualTo("/")
                .havingHeaderEqualTo("X-Amz-Target", "TrentService.DescribeKey")
                .respond()
                .withStatus(200)
                .withContentType("application/json")
                .withBody(new FileReader("target/test-classes/services/aws-describekey-rsa.json"));
        onRequest()
                .havingMethodEqualTo("POST")
                .havingPathEqualTo("/")
                .havingHeaderEqualTo("X-Amz-Target", "TrentService.Sign")
                .respond()
                .withStatus(400)
                .withContentType("application/json")
                .withBody("{\"__type\":\"KMSInvalidStateException\",\"message\":\"arn:aws:kms:eu-west-3:829022948260:key/935ecb66-5c06-495b-babe-5798b1c0e1a8 is pending deletion.\"}");

        SigningService service = getTestService();
        SigningServicePrivateKey privateKey = service.getPrivateKey("jsign-rsa-2048", null);

        Exception e = assertThrows(GeneralSecurityException.class, () -> service.sign(privateKey, "SHA256withRSA", "Hello".getBytes()));
        assertEquals("message", "KMSInvalidStateException: arn:aws:kms:eu-west-3:829022948260:key/935ecb66-5c06-495b-babe-5798b1c0e1a8 is pending deletion.", e.getCause().getMessage());
    }

    @Test
    public void testSignRequestWithoutSessionToken() throws Exception {
        testSignRequest(false);
    }

    @Test
    public void testSignRequestWithSessionToken() throws Exception {
        testSignRequest(true);
    }

    public void testSignRequest(boolean useSessionToken) throws Exception {
        AmazonCredentials credentials = new AmazonCredentials("accessKey", "secretKey",useSessionToken ? "sessionToken" : null);
        AmazonSigningService service = new AmazonSigningService("eu-west-3", credentials, null);

        URL url = new URL("https://kms.eu-west-3.amazonaws.com");
        HttpURLConnection conn = (HttpURLConnection) url.openConnection();
        conn.setRequestMethod("POST");
        conn.setRequestProperty("User-Agent", "Jsign (https://ebourg.github.io/jsign/)");
        conn.setRequestProperty("X-Amz-Target", "TrentService.ListKeys");
        conn.setRequestProperty("Content-Type", "application/x-amz-json-1.1");
        service.sign(conn, credentials, "{}".getBytes(), new Date(0));

        assertEquals("X-Amz-Date", "19700101T000000Z", conn.getRequestProperty("X-Amz-Date"));
        assertEquals("X-Amz-Security-Token", credentials.getSessionToken(), conn.getRequestProperty("X-Amz-Security-Token"));
        assertEquals("Authorization", "AWS4-HMAC-SHA256 Credential=accessKey/19700101/eu-west-3/kms/aws4_request, SignedHeaders=content-type;host;user-agent;x-amz-date;x-amz-target, Signature=6247e3c7f2e50e806e32843924b94c860b6a3721fd12f9b99d8d8d140795e4c5", getAuthorizationHeaderValue(conn));
    }

    private String getAuthorizationHeaderValue(HttpURLConnection conn) throws Exception {
        Field delegate = sun.net.www.protocol.https.HttpsURLConnectionImpl.class.getDeclaredField("delegate");
        Field requests = sun.net.www.protocol.http.HttpURLConnection.class.getDeclaredField("requests");
        AccessibleObject.setAccessible(new Field[]{delegate, requests}, true);
        sun.net.www.MessageHeader headers = (sun.net.www.MessageHeader) requests.get(delegate.get(conn));
        return headers.findValue("Authorization");
    }

    @Test
    public void testGetEndpointUrl() {
        // Test default endpoint
        try (MockedStatic<?> mock = mockStatic(AmazonSigningService.class, CALLS_REAL_METHODS)) {
            when(AmazonSigningService.getenv("AWS_USE_FIPS_ENDPOINT")).thenReturn("false");
            assertEquals("https://kms.us-west-2.amazonaws.com", AmazonSigningService.getEndpointUrl("us-west-2"));
        }

        // Test FIPS endpoint
        try (MockedStatic<?> mock = mockStatic(AmazonSigningService.class, CALLS_REAL_METHODS)) {
            when(AmazonSigningService.getenv("AWS_USE_FIPS_ENDPOINT")).thenReturn("true");
            assertEquals("https://kms-fips.us-west-2.amazonaws.com", AmazonSigningService.getEndpointUrl("us-west-2"));
        }
    }
}