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}