APPXFileTest.java

/*
 * Copyright 2023 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.appx;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import javax.security.auth.x500.X500Principal;

import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.junit.Test;

import net.jsign.AuthenticodeSigner;
import net.jsign.KeyStoreBuilder;

import static java.nio.charset.StandardCharsets.*;
import static net.jsign.DigestAlgorithm.*;
import static net.jsign.SignatureAssert.*;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;

public class APPXFileTest {

    @Test
    public void testGetSignaturesFromUnsignedPackage() throws Exception {
        try (APPXFile file = new APPXFile(new File("target/test-classes/minimal.msix"))) {
            assertTrue("signature found", file.getSignatures().isEmpty());
        }
    }

    @Test
    public void testGetSignaturesFromSignedPackage() throws Exception {
        try (APPXFile file = new APPXFile(new File("target/test-classes/minimal-signed-by-signtool.msix"))) {
            assertFalse("signature not found", file.getSignatures().isEmpty());
        }
    }

    @Test
    public void testAddContentType() throws Exception {
        File unsignedFile = new File("target/test-classes/minimal.msix");
        File modified = new File("target/test-classes/minimal-with-content-types-modified.msix");

        FileUtils.copyFile(unsignedFile, modified);

        // modify the content types
        try (APPXFile msix = new APPXFile(modified)) {
            msix.addContentType("/foo.txt", "text/foo");
            msix.addContentType("/foo.txt", "text/foo");
        }

        try (APPXFile msix = new APPXFile(modified)) {
            String contentTypes = new String(IOUtils.toByteArray(msix.getInputStream("[Content_Types].xml")), UTF_8);
            assertTrue("missing content type", contentTypes.contains("<Override PartName=\"/foo.txt\" ContentType=\"text/foo\"/>"));
            assertEquals("number of content types added", 1, StringUtils.countMatches(contentTypes, "text/foo"));
        }
    }

    @Test
    public void testRemoveSignature() throws Exception {
        File sourceFile = new File("target/test-classes/minimal.msix");
        File targetFile = new File("target/test-classes/minimal-unsigned.msix");

        FileUtils.copyFile(sourceFile, targetFile);

        KeyStore keystore = new KeyStoreBuilder().keystore("target/test-classes/keystores/keystore.jks").storepass("password").build();
        AuthenticodeSigner signer = new AuthenticodeSigner(keystore, "test", "password").withTimestamping(false);

        try (APPXFile file = new APPXFile(targetFile)) {
            file.setSignature(null);
            signer.sign(file);
            assertSigned(file, SHA256);
            file.setSignature(null);
            assertNotSigned(file);
        }
    }

    @Test
    public void testIsBundle() throws Exception {
        try (APPXFile file = new APPXFile(new File("target/test-classes/minimal.msix"))) {
            assertFalse("minimal.msix is a bundle", file.isBundle());
        }
        try (APPXFile file = new APPXFile(new File("target/test-classes/minimal.appxbundle"))) {
            assertTrue("minimal.appxbundle is not a bundle", file.isBundle());
        }
    }

    @Test
    public void testGetPackagePublisher() throws Exception {
        try (APPXFile file = new APPXFile(new File("target/test-classes/minimal.msix"))) {
            assertEquals("Publisher", "CN=Jsign Code Signing Test Certificate 2024 (RSA)", file.getPublisher());
        }
    }

    @Test
    public void testGetBundlePublisher() throws Exception {
        try (APPXFile file = new APPXFile(new File("target/test-classes/minimal.appxbundle"))) {
            assertEquals("Publisher", "CN=Jsign Code Signing Test Certificate 2024 (RSA)", file.getPublisher());
        }
    }

    public static Certificate getCertificate() throws IOException, CertificateException {
        try (FileInputStream in = new FileInputStream("target/test-classes/keystores/jsign-test-certificate.pem")) {
            return CertificateFactory.getInstance("X.509").generateCertificates(in).iterator().next();
        }
    }

    @Test
    public void testValidateWithMatchingPublisher() throws Exception {
        try (APPXFile file = new APPXFile(new File("target/test-classes/minimal.msix"))) {
            file.validate(getCertificate());
        }
    }

    @Test
    public void testValidateWithMismatchingPublisher() throws Exception {
        try (APPXFile file = spy(new APPXFile(new File("target/test-classes/minimal.msix")))) {
            when(file.getPublisher()).thenReturn("CN=Jsign Code Signing Test Certificate 1977 (RSA)");
            Exception e = assertThrows(IllegalArgumentException.class, () -> file.validate(getCertificate()));
            assertEquals("message", "The app manifest publisher name (CN=Jsign Code Signing Test Certificate 1977 (RSA)) must match the subject name of the signing certificate (CN=Jsign Code Signing Test Certificate 2024 (RSA))", e.getMessage());
        }
    }

    @Test
    public void testValidateWithReorderedPublisher() throws Exception {
        try (APPXFile file = spy(new APPXFile(new File("target/test-classes/minimal.msix")))) {
            when(file.getPublisher()).thenReturn("C=US, S=New York,  L=New York, O=\"COMPANY, INC.\",CN=\"COMPANY, INC.\"");

            X509Certificate certificate = spy((X509Certificate) getCertificate());
            when(certificate.getSubjectX500Principal()).thenReturn(new X500Principal("CN=\"COMPANY, INC.\",O=\"COMPANY, INC.\",L=New York,ST=New York,C=US"));
            file.validate(certificate);
        }
    }

    @Test
    public void testValidateWithMissingPublisher() throws Exception {
        try (APPXFile file = spy(new APPXFile(new File("target/test-classes/minimal.msix")))) {
            when(file.getPublisher()).thenReturn(null);
            Exception e = assertThrows(IllegalArgumentException.class, () -> file.validate(getCertificate()));
            assertEquals("message", "The app manifest publisher name (null) must match the subject name of the signing certificate (CN=Jsign Code Signing Test Certificate 2024 (RSA))", e.getMessage());
        }
    }
}