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 {
}
}