001/*
002 * Copyright 2023 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.appx;
018
019import java.io.ByteArrayOutputStream;
020import java.io.File;
021import java.io.IOException;
022import java.io.InputStream;
023import java.nio.channels.SeekableByteChannel;
024import java.security.DigestOutputStream;
025import java.security.MessageDigest;
026import java.security.cert.Certificate;
027import java.security.cert.X509Certificate;
028import java.util.ArrayList;
029import java.util.List;
030import java.util.regex.Matcher;
031import java.util.regex.Pattern;
032
033import org.apache.commons.io.output.NullOutputStream;
034import org.apache.commons.text.StringEscapeUtils;
035import org.apache.poi.util.IOUtils;
036import org.bouncycastle.asn1.ASN1Object;
037import org.bouncycastle.asn1.DERNull;
038import org.bouncycastle.asn1.x500.X500Name;
039import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
040import org.bouncycastle.asn1.x509.DigestInfo;
041import org.bouncycastle.cms.CMSSignedData;
042
043import net.jsign.ChannelUtils;
044import net.jsign.DigestAlgorithm;
045import net.jsign.Signable;
046import net.jsign.SignatureUtils;
047import net.jsign.asn1.authenticode.AuthenticodeObjectIdentifiers;
048import net.jsign.asn1.authenticode.SpcAttributeTypeAndOptionalValue;
049import net.jsign.asn1.authenticode.SpcIndirectDataContent;
050import net.jsign.asn1.authenticode.SpcSipInfo;
051import net.jsign.asn1.authenticode.SpcUuid;
052import net.jsign.zip.CentralDirectory;
053import net.jsign.zip.ZipFile;
054
055import static java.nio.charset.StandardCharsets.*;
056
057/**
058 * APPX/MSIX package.
059 *
060 * @author Emmanuel Bourg
061 * @since 6.0
062 */
063public class APPXFile extends ZipFile implements Signable {
064
065    /** The name of the package signature entry in the archive */
066    private static final String SIGNATURE_ENTRY = "AppxSignature.p7x";
067
068    /**
069     * Create an APPXFile from the specified file.
070     *
071     * @param file the file to open
072     * @throws IOException if an I/O error occurs
073     */
074    public APPXFile(File file) throws IOException {
075        super(file);
076        verifyPackage();
077    }
078
079    /**
080     * Create an APPXFile from the specified channel.
081     *
082     * @param channel the channel to read the file from
083     * @throws IOException if an I/O error occurs
084     */
085    public APPXFile(SeekableByteChannel channel) throws IOException {
086        super(channel);
087        verifyPackage();
088    }
089
090    private void verifyPackage() throws IOException {
091        if (centralDirectory.entries.get("[Content_Types].xml") == null) {
092            throw new IOException("Invalid APPX/MSIX package, [Content_Types].xml is missing");
093        }
094    }
095
096    @Override
097    public byte[] computeDigest(DigestAlgorithm digestAlgorithm) throws IOException {
098        addContentType("/" + SIGNATURE_ENTRY, "application/vnd.ms-appx.signature");
099
100        // digest the file records
101        long endOfContentOffset = centralDirectory.centralDirectoryOffset;
102        if (centralDirectory.entries.containsKey(SIGNATURE_ENTRY)) {
103            endOfContentOffset = centralDirectory.entries.get(SIGNATURE_ENTRY).getLocalHeaderOffset();
104        }
105        MessageDigest axpc = digestAlgorithm.getMessageDigest();
106        ChannelUtils.updateDigest(channel, axpc, 0, endOfContentOffset);
107
108        // digest the central directory
109        MessageDigest axcd = digestAlgorithm.getMessageDigest();
110        axcd.update(getUnsignedCentralDirectory());
111
112        // digest the [ContentTypes].xml file
113        MessageDigest axct = digestAlgorithm.getMessageDigest();
114        IOUtils.copy(getInputStream("[Content_Types].xml"), new DigestOutputStream(NullOutputStream.INSTANCE, axct));
115
116        // digest the AppxBlockMap.xml file
117        MessageDigest axbm = digestAlgorithm.getMessageDigest();
118        IOUtils.copy(getInputStream("AppxBlockMap.xml"), new DigestOutputStream(NullOutputStream.INSTANCE, axbm));
119
120        // digest the AppxMetadata/CodeIntegrity.cat file if present
121        MessageDigest axci = null;
122        if (centralDirectory.entries.containsKey("AppxMetadata/CodeIntegrity.cat")) {
123            axci = digestAlgorithm.getMessageDigest();
124            IOUtils.copy(getInputStream("AppxMetadata/CodeIntegrity.cat"), new DigestOutputStream(NullOutputStream.INSTANCE, axci));
125        }
126
127        ByteArrayOutputStream out = new ByteArrayOutputStream();
128        out.write("APPX".getBytes());
129        out.write("AXPC".getBytes());
130        out.write(axpc.digest());
131        out.write("AXCD".getBytes());
132        out.write(axcd.digest());
133        out.write("AXCT".getBytes());
134        out.write(axct.digest());
135        out.write("AXBM".getBytes());
136        out.write(axbm.digest());
137        if (axci != null) {
138            out.write("AXCI".getBytes());
139            out.write(axci.digest());
140        }
141
142        return out.toByteArray();
143    }
144
145    /**
146     * Returns a copy of the central directory as if the package was unsigned.
147     */
148    private byte[] getUnsignedCentralDirectory() throws IOException {
149        CentralDirectory centralDirectory = new CentralDirectory();
150        centralDirectory.read(channel);
151        centralDirectory.removeEntry(SIGNATURE_ENTRY);
152        return centralDirectory.toBytes();
153    }
154
155    @Override
156    public ASN1Object createIndirectData(DigestAlgorithm digestAlgorithm) throws IOException {
157        AlgorithmIdentifier algorithmIdentifier = new AlgorithmIdentifier(digestAlgorithm.oid, DERNull.INSTANCE);
158        DigestInfo digestInfo = new DigestInfo(algorithmIdentifier, computeDigest(digestAlgorithm));
159
160        SpcUuid uuid = new SpcUuid(isBundle() ? "B3585F0F-DEAA-9A4B-A434-95742D92ECEB" : "4BDFC50A-07CE-E24D-B76E-23C839A09FD1");
161        SpcAttributeTypeAndOptionalValue data = new SpcAttributeTypeAndOptionalValue(AuthenticodeObjectIdentifiers.SPC_SIPINFO_OBJID, new SpcSipInfo(0x01010000, uuid));
162
163        return new SpcIndirectDataContent(data, digestInfo);
164    }
165
166    /**
167     * Normalize the X500 name specified.
168     */
169    private String normalize(String name) {
170        if (name != null) {
171            // replace the non-standard S abbreviation used by Microsoft with ST for the stateOrProvinceName attribute
172            name = name.replaceAll(",\\s*S\\s*=", ",ST=");
173        }
174
175        return name;
176    }
177
178    @Override
179    public void validate(Certificate certificate) throws IOException, IllegalArgumentException {
180        X500Name name = X500Name.getInstance(((X509Certificate) certificate).getSubjectX500Principal().getEncoded());
181        String publisher = getPublisher();
182
183        if (publisher == null || !name.equals(new X500Name(normalize(publisher)))) {
184            throw new IllegalArgumentException("The app manifest publisher name (" + publisher + ") must match the subject name of the signing certificate (" + name + ")");
185        }
186    }
187
188    /**
189     * Tells if the package is a bundle.
190     */
191    boolean isBundle() {
192        return centralDirectory.entries.containsKey("AppxMetadata/AppxBundleManifest.xml");
193    }
194
195    /**
196     * Returns the publisher of the package.
197     */
198    String getPublisher() throws IOException {
199        InputStream in = getInputStream(isBundle() ? "AppxMetadata/AppxBundleManifest.xml" : "AppxManifest.xml", 10 * 1024 * 1024 /* 10MB */);
200        String manifest = new String(IOUtils.toByteArray(in), UTF_8);
201
202        Pattern pattern = Pattern.compile("Publisher\\s*=\\s*\"([^\"]+)", Pattern.CASE_INSENSITIVE);
203        Matcher matcher = pattern.matcher(manifest);
204        return matcher.find() ? StringEscapeUtils.unescapeXml(matcher.group(1)) : null;
205    }
206
207    @Override
208    public List<CMSSignedData> getSignatures() throws IOException {
209        if (centralDirectory.entries.containsKey(SIGNATURE_ENTRY)) {
210            InputStream in = getInputStream(SIGNATURE_ENTRY, 1024 * 1024 /* 1MB */);
211            // skip the "PKCX" header
212            in.skip(4);
213
214            return SignatureUtils.getSignatures(IOUtils.toByteArray(in));
215        }
216
217        return new ArrayList<>();
218    }
219
220    @Override
221    public void setSignature(CMSSignedData signature) throws IOException {
222        if (centralDirectory.entries.containsKey(SIGNATURE_ENTRY)) {
223            removeEntry(SIGNATURE_ENTRY);
224        }
225
226        if (signature != null) {
227            ByteArrayOutputStream out = new ByteArrayOutputStream();
228            out.write("PKCX".getBytes());
229            signature.toASN1Structure().encodeTo(out, "DER");
230
231            addEntry(SIGNATURE_ENTRY, out.toByteArray(), false);
232        }
233    }
234
235    /**
236     * Add a content type to the [ContentTypes].xml file.
237     */
238    void addContentType(String partName, String contentType) throws IOException {
239        InputStream in = getInputStream("[Content_Types].xml", 10 * 1024 * 1024 /* 10MB */);
240        String contentTypes = new String(IOUtils.toByteArray(in), UTF_8);
241        String override = "<Override PartName=\"" + partName + "\" ContentType=\"" + contentType + "\"/>";
242        if (!contentTypes.contains(override)) {
243            contentTypes = contentTypes.replace("</Types>", "<Override PartName=\"" + partName + "\" ContentType=\"" + contentType + "\"/></Types>");
244
245            removeEntry("[Content_Types].xml");
246            addEntry("[Content_Types].xml", contentTypes.getBytes(), true);
247        }
248    }
249
250    @Override
251    public void save() throws IOException {
252    }
253}