APPXFile.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.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.channels.SeekableByteChannel;
import java.security.DigestOutputStream;
import java.security.MessageDigest;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.util.Collections;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import org.apache.commons.io.output.NullOutputStream;
import org.apache.commons.text.StringEscapeUtils;
import org.apache.poi.util.IOUtils;
import org.bouncycastle.asn1.ASN1Object;
import org.bouncycastle.asn1.DERNull;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.asn1.x509.DigestInfo;
import org.bouncycastle.cms.CMSSignedData;

import net.jsign.ChannelUtils;
import net.jsign.DigestAlgorithm;
import net.jsign.Signable;
import net.jsign.SignatureUtils;
import net.jsign.asn1.authenticode.AuthenticodeObjectIdentifiers;
import net.jsign.asn1.authenticode.SpcAttributeTypeAndOptionalValue;
import net.jsign.asn1.authenticode.SpcIndirectDataContent;
import net.jsign.asn1.authenticode.SpcSipInfo;
import net.jsign.asn1.authenticode.SpcUuid;
import net.jsign.zip.CentralDirectory;
import net.jsign.zip.ZipFile;

import static java.nio.charset.StandardCharsets.*;

/**
 * APPX/MSIX package.
 *
 * @author Emmanuel Bourg
 * @since 6.0
 */
public class APPXFile extends ZipFile implements Signable {

    /** The name of the package signature entry in the archive */
    private static final String SIGNATURE_ENTRY = "AppxSignature.p7x";

    /**
     * Create an APPXFile from the specified file.
     *
     * @param file the file to open
     * @throws IOException if an I/O error occurs
     */
    public APPXFile(File file) throws IOException {
        super(file);
        verifyPackage();
    }

    /**
     * Create an APPXFile from the specified channel.
     *
     * @param channel the channel to read the file from
     * @throws IOException if an I/O error occurs
     */
    public APPXFile(SeekableByteChannel channel) throws IOException {
        super(channel);
        verifyPackage();
    }

    private void verifyPackage() throws IOException {
        if (centralDirectory.entries.get("[Content_Types].xml") == null) {
            throw new IOException("Invalid APPX/MSIX package, [Content_Types].xml is missing");
        }
    }

    @Override
    public byte[] computeDigest(DigestAlgorithm digestAlgorithm) throws IOException {
        addContentType("/" + SIGNATURE_ENTRY, "application/vnd.ms-appx.signature");

        // digest the file records
        long endOfContentOffset = centralDirectory.centralDirectoryOffset;
        if (centralDirectory.entries.containsKey(SIGNATURE_ENTRY)) {
            endOfContentOffset = centralDirectory.entries.get(SIGNATURE_ENTRY).getLocalHeaderOffset();
        }
        MessageDigest axpc = digestAlgorithm.getMessageDigest();
        ChannelUtils.updateDigest(channel, axpc, 0, endOfContentOffset);

        // digest the central directory
        MessageDigest axcd = digestAlgorithm.getMessageDigest();
        axcd.update(getUnsignedCentralDirectory());

        // digest the [ContentTypes].xml file
        MessageDigest axct = digestAlgorithm.getMessageDigest();
        IOUtils.copy(getInputStream("[Content_Types].xml"), new DigestOutputStream(NullOutputStream.INSTANCE, axct));

        // digest the AppxBlockMap.xml file
        MessageDigest axbm = digestAlgorithm.getMessageDigest();
        IOUtils.copy(getInputStream("AppxBlockMap.xml"), new DigestOutputStream(NullOutputStream.INSTANCE, axbm));

        // digest the AppxMetadata/CodeIntegrity.cat file if present
        MessageDigest axci = null;
        if (centralDirectory.entries.containsKey("AppxMetadata/CodeIntegrity.cat")) {
            axci = digestAlgorithm.getMessageDigest();
            IOUtils.copy(getInputStream("AppxMetadata/CodeIntegrity.cat"), new DigestOutputStream(NullOutputStream.INSTANCE, axci));
        }

        ByteArrayOutputStream out = new ByteArrayOutputStream();
        out.write("APPX".getBytes());
        out.write("AXPC".getBytes());
        out.write(axpc.digest());
        out.write("AXCD".getBytes());
        out.write(axcd.digest());
        out.write("AXCT".getBytes());
        out.write(axct.digest());
        out.write("AXBM".getBytes());
        out.write(axbm.digest());
        if (axci != null) {
            out.write("AXCI".getBytes());
            out.write(axci.digest());
        }

        return out.toByteArray();
    }

    /**
     * Returns a copy of the central directory as if the package was unsigned.
     */
    private byte[] getUnsignedCentralDirectory() throws IOException {
        CentralDirectory centralDirectory = new CentralDirectory();
        centralDirectory.read(channel);
        centralDirectory.removeEntry(SIGNATURE_ENTRY);
        return centralDirectory.toBytes();
    }

    @Override
    public ASN1Object createIndirectData(DigestAlgorithm digestAlgorithm) throws IOException {
        AlgorithmIdentifier algorithmIdentifier = new AlgorithmIdentifier(digestAlgorithm.oid, DERNull.INSTANCE);
        DigestInfo digestInfo = new DigestInfo(algorithmIdentifier, computeDigest(digestAlgorithm));

        SpcUuid uuid = new SpcUuid(isBundle() ? "B3585F0F-DEAA-9A4B-A434-95742D92ECEB" : "4BDFC50A-07CE-E24D-B76E-23C839A09FD1");
        SpcAttributeTypeAndOptionalValue data = new SpcAttributeTypeAndOptionalValue(AuthenticodeObjectIdentifiers.SPC_SIPINFO_OBJID, new SpcSipInfo(0x01010000, uuid));

        return new SpcIndirectDataContent(data, digestInfo);
    }

    /**
     * Normalize the X500 name specified.
     */
    private String normalize(String name) {
        if (name != null) {
            // replace the non-standard S abbreviation used by Microsoft with ST for the stateOrProvinceName attribute
            name = name.replaceAll(",\\s*S\\s*=", ",ST=");
        }

        return name;
    }

    @Override
    public void validate(Certificate certificate) throws IOException, IllegalArgumentException {
        X500Name name = X500Name.getInstance(((X509Certificate) certificate).getSubjectX500Principal().getEncoded());
        String publisher = getPublisher();

        if (publisher == null || !name.equals(new X500Name(normalize(publisher)))) {
            throw new IllegalArgumentException("The app manifest publisher name (" + publisher + ") must match the subject name of the signing certificate (" + name + ")");
        }
    }

    /**
     * Tells if the package is a bundle.
     */
    boolean isBundle() {
        return centralDirectory.entries.containsKey("AppxMetadata/AppxBundleManifest.xml");
    }

    /**
     * Returns the publisher of the package.
     */
    String getPublisher() throws IOException {
        InputStream in = getInputStream(isBundle() ? "AppxMetadata/AppxBundleManifest.xml" : "AppxManifest.xml", 10 * 1024 * 1024 /* 10MB */);
        String manifest = new String(IOUtils.toByteArray(in), UTF_8);

        Pattern pattern = Pattern.compile("Publisher\\s*=\\s*\"([^\"]+)", Pattern.CASE_INSENSITIVE);
        Matcher matcher = pattern.matcher(manifest);
        return matcher.find() ? StringEscapeUtils.unescapeXml(matcher.group(1)) : null;
    }

    @Override
    public List<CMSSignedData> getSignatures() throws IOException {
        if (centralDirectory.entries.containsKey(SIGNATURE_ENTRY)) {
            InputStream in = getInputStream(SIGNATURE_ENTRY, 1024 * 1024 /* 1MB */);
            // skip the "PKCX" header
            in.skip(4);

            return SignatureUtils.getSignatures(IOUtils.toByteArray(in));
        }

        return Collections.emptyList();
    }

    @Override
    public void setSignature(CMSSignedData signature) throws IOException {
        if (centralDirectory.entries.containsKey(SIGNATURE_ENTRY)) {
            removeEntry(SIGNATURE_ENTRY);
        }

        if (signature != null) {
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            out.write("PKCX".getBytes());
            signature.toASN1Structure().encodeTo(out, "DER");

            addEntry(SIGNATURE_ENTRY, out.toByteArray(), false);
        }
    }

    /**
     * Add a content type to the [ContentTypes].xml file.
     */
    void addContentType(String partName, String contentType) throws IOException {
        InputStream in = getInputStream("[Content_Types].xml", 10 * 1024 * 1024 /* 10MB */);
        String contentTypes = new String(IOUtils.toByteArray(in), UTF_8);
        String override = "<Override PartName=\"" + partName + "\" ContentType=\"" + contentType + "\"/>";
        if (!contentTypes.contains(override)) {
            contentTypes = contentTypes.replace("</Types>", "<Override PartName=\"" + partName + "\" ContentType=\"" + contentType + "\"/></Types>");

            removeEntry("[Content_Types].xml");
            addEntry("[Content_Types].xml", contentTypes.getBytes(), true);
        }
    }

    @Override
    public void save() throws IOException {
    }
}