001/** 002 * Copyright 2019 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.msi; 018 019import java.io.ByteArrayInputStream; 020import java.io.DataInputStream; 021import java.io.File; 022import java.io.FileInputStream; 023import java.io.FileNotFoundException; 024import java.io.FilterInputStream; 025import java.io.IOException; 026import java.io.InputStream; 027import java.nio.ByteBuffer; 028import java.nio.channels.Channels; 029import java.nio.channels.SeekableByteChannel; 030import java.security.MessageDigest; 031import java.util.ArrayList; 032import java.util.List; 033import java.util.Map; 034import java.util.NoSuchElementException; 035import java.util.TreeMap; 036 037import org.apache.poi.poifs.filesystem.DocumentEntry; 038import org.apache.poi.poifs.filesystem.DocumentInputStream; 039import org.apache.poi.poifs.filesystem.POIFSDocument; 040import org.apache.poi.poifs.filesystem.POIFSFileSystem; 041import org.apache.poi.poifs.property.DirectoryProperty; 042import org.apache.poi.poifs.property.DocumentProperty; 043import org.apache.poi.poifs.property.Property; 044import org.apache.poi.util.IOUtils; 045import org.bouncycastle.asn1.ASN1Encodable; 046import org.bouncycastle.asn1.ASN1InputStream; 047import org.bouncycastle.asn1.ASN1Object; 048import org.bouncycastle.asn1.DERNull; 049import org.bouncycastle.asn1.cms.Attribute; 050import org.bouncycastle.asn1.cms.AttributeTable; 051import org.bouncycastle.asn1.cms.ContentInfo; 052import org.bouncycastle.asn1.x509.AlgorithmIdentifier; 053import org.bouncycastle.asn1.x509.DigestInfo; 054import org.bouncycastle.cms.CMSProcessable; 055import org.bouncycastle.cms.CMSSignedData; 056import org.bouncycastle.cms.SignerInformation; 057 058import net.jsign.DigestAlgorithm; 059import net.jsign.Signable; 060import net.jsign.asn1.authenticode.AuthenticodeObjectIdentifiers; 061import net.jsign.asn1.authenticode.SpcAttributeTypeAndOptionalValue; 062import net.jsign.asn1.authenticode.SpcIndirectDataContent; 063import net.jsign.asn1.authenticode.SpcSipInfo; 064import net.jsign.asn1.authenticode.SpcUuid; 065 066/** 067 * A Microsoft Installer package. 068 * 069 * @author Emmanuel Bourg 070 * @since 3.0 071 */ 072public class MSIFile implements Signable { 073 074 private static final long MSI_HEADER = 0xD0CF11E0A1B11AE1L; 075 076 private static final String DIGITAL_SIGNATURE_ENTRY_NAME = "\u0005DigitalSignature"; 077 private static final String MSI_DIGITAL_SIGNATURE_EX_ENTRY_NAME = "\u0005MsiDigitalSignatureEx"; 078 079 /** 080 * The POI filesystem used for reading the file. A separate filesystem has 081 * to be used because POI maps the file in memory in read/write mode and 082 * this leads to OOM errors when the file is parsed. 083 * See https://github.com/ebourg/jsign/issues/82 for more info. 084 */ 085 private POIFSFileSystem fsRead; 086 087 /** The POI filesystem used for writing to the file */ 088 private final POIFSFileSystem fsWrite; 089 090 /** The channel used for in-memory signing */ 091 private SeekableByteChannel channel; 092 093 /** The underlying file */ 094 private File file; 095 096 /** 097 * Tells if the specified file is a MSI file. 098 * 099 * @param file the file to check 100 * @return <code>true</code> if the file is a Microsoft installer, <code>false</code> otherwise 101 * @throws IOException if an I/O error occurs 102 */ 103 public static boolean isMSIFile(File file) throws IOException { 104 try (DataInputStream in = new DataInputStream(new FileInputStream(file))) { 105 return in.readLong() == MSI_HEADER; 106 } 107 } 108 109 /** 110 * Create a MSIFile from the specified file. 111 * 112 * @param file the file to open 113 * @throws IOException if an I/O error occurs 114 */ 115 public MSIFile(File file) throws IOException { 116 this.file = file; 117 try { 118 this.fsRead = new POIFSFileSystem(file, true); 119 this.fsWrite = new POIFSFileSystem(file, false); 120 } catch (IndexOutOfBoundsException | IllegalStateException | ClassCastException e) { 121 throw new IOException("MSI file format error", e); 122 } 123 } 124 125 /** 126 * Create a MSIFile from the specified channel. 127 * 128 * @param channel the channel to read the file from 129 * @throws IOException if an I/O error occurs 130 */ 131 public MSIFile(final SeekableByteChannel channel) throws IOException { 132 this.channel = channel; 133 InputStream in = new FilterInputStream(Channels.newInputStream(channel)) { 134 public void close() { } 135 }; 136 this.fsRead = new POIFSFileSystem(in); 137 this.fsWrite = fsRead; 138 } 139 140 /** 141 * Closes the file 142 * 143 * @throws IOException if an I/O error occurs 144 */ 145 public void close() throws IOException { 146 try (POIFSFileSystem fsRead = this.fsRead; POIFSFileSystem fsWrite = this.fsWrite; SeekableByteChannel channel = this.channel) { 147 // do nothing 148 } 149 } 150 151 /** 152 * Tells if the MSI file has an extended signature (MsiDigitalSignatureEx) 153 * containing a hash of the streams metadata (name, size, date). 154 * 155 * @return <code>true</code> if the file has a MsiDigitalSignatureEx stream, <code>false</code> otherwise 156 */ 157 public boolean hasExtendedSignature() { 158 try { 159 fsRead.getRoot().getEntry(MSI_DIGITAL_SIGNATURE_EX_ENTRY_NAME); 160 return true; 161 } catch (FileNotFoundException e) { 162 return false; 163 } 164 } 165 166 private List<Property> getSortedProperties() { 167 List<Property> entries = new ArrayList<>(); 168 169 append(fsRead.getPropertyTable().getRoot(), entries); 170 171 return entries; 172 } 173 174 private void append(DirectoryProperty node, List<Property> entries) { 175 Map<MSIStreamName, Property> sortedEntries = new TreeMap<>(); 176 for (Property entry : node) { 177 sortedEntries.put(new MSIStreamName(entry.getName()), entry); 178 } 179 180 for (Property property : sortedEntries.values()) { 181 if (!property.isDirectory()) { 182 entries.add(property); 183 } else { 184 append((DirectoryProperty) property, entries); 185 } 186 } 187 } 188 189 @Override 190 public byte[] computeDigest(MessageDigest digest) throws IOException { 191 try { 192 // hash the entries 193 for (Property property : getSortedProperties()) { 194 String name = new MSIStreamName(property.getName()).decode(); 195 if (name.equals(DIGITAL_SIGNATURE_ENTRY_NAME) || name.equals(MSI_DIGITAL_SIGNATURE_EX_ENTRY_NAME)) { 196 continue; 197 } 198 199 POIFSDocument document = new POIFSDocument((DocumentProperty) property, fsRead); 200 long remaining = document.getSize(); 201 for (ByteBuffer buffer : document) { 202 int size = buffer.remaining(); 203 buffer.limit(buffer.position() + (int) Math.min(remaining, size)); 204 digest.update(buffer); 205 remaining -= size; 206 } 207 } 208 209 // hash the package ClassID, in serialized form 210 byte[] classId = new byte[16]; 211 fsRead.getRoot().getStorageClsid().write(classId, 0); 212 digest.update(classId); 213 214 return digest.digest(); 215 } catch (IndexOutOfBoundsException | IllegalArgumentException | IllegalStateException | NoSuchElementException e) { 216 throw new IOException("MSI file format error", e); 217 } 218 } 219 220 @Override 221 public ASN1Object createIndirectData(DigestAlgorithm digestAlgorithm) throws IOException { 222 AlgorithmIdentifier algorithmIdentifier = new AlgorithmIdentifier(digestAlgorithm.oid, DERNull.INSTANCE); 223 DigestInfo digestInfo = new DigestInfo(algorithmIdentifier, computeDigest(digestAlgorithm.getMessageDigest())); 224 225 SpcUuid uuid = new SpcUuid("F1100C00-0000-0000-C000-000000000046"); 226 SpcAttributeTypeAndOptionalValue data = new SpcAttributeTypeAndOptionalValue(AuthenticodeObjectIdentifiers.SPC_SIPINFO_OBJID, new SpcSipInfo(1, uuid)); 227 228 return new SpcIndirectDataContent(data, digestInfo); 229 } 230 231 @Override 232 public List<CMSSignedData> getSignatures() throws IOException { 233 List<CMSSignedData> signatures = new ArrayList<>(); 234 235 try { 236 DocumentEntry digitalSignature = (DocumentEntry) fsRead.getRoot().getEntry(DIGITAL_SIGNATURE_ENTRY_NAME); 237 if (digitalSignature != null) { 238 byte[] signatureBytes = IOUtils.toByteArray(new DocumentInputStream(digitalSignature)); 239 try { 240 CMSSignedData signedData = new CMSSignedData((CMSProcessable) null, ContentInfo.getInstance(new ASN1InputStream(signatureBytes).readObject())); 241 signatures.add(signedData); 242 243 // look for nested signatures 244 SignerInformation signerInformation = signedData.getSignerInfos().getSigners().iterator().next(); 245 AttributeTable unsignedAttributes = signerInformation.getUnsignedAttributes(); 246 if (unsignedAttributes != null) { 247 Attribute nestedSignatures = unsignedAttributes.get(AuthenticodeObjectIdentifiers.SPC_NESTED_SIGNATURE_OBJID); 248 if (nestedSignatures != null) { 249 for (ASN1Encodable nestedSignature : nestedSignatures.getAttrValues()) { 250 signatures.add(new CMSSignedData((CMSProcessable) null, ContentInfo.getInstance(nestedSignature))); 251 } 252 } 253 } 254 } catch (UnsupportedOperationException e) { 255 // unsupported type, just skip 256 } catch (Exception e) { 257 e.printStackTrace(); 258 } 259 } 260 } catch (FileNotFoundException e) { 261 } 262 263 return signatures; 264 } 265 266 @Override 267 public void setSignature(CMSSignedData signature) throws IOException { 268 byte[] signatureBytes = signature.toASN1Structure().getEncoded("DER"); 269 try { 270 fsWrite.getRoot().createOrUpdateDocument(DIGITAL_SIGNATURE_ENTRY_NAME, new ByteArrayInputStream(signatureBytes)); 271 } catch (IndexOutOfBoundsException e) { 272 throw new IOException("MSI file format error", e); 273 } 274 } 275 276 @Override 277 public void save() throws IOException { 278 if (channel == null) { 279 fsWrite.writeFilesystem(); 280 fsRead.close(); 281 try { 282 fsRead = new POIFSFileSystem(file, true); 283 } catch (IndexOutOfBoundsException e) { 284 throw new IOException("MSI file format error", e); 285 } 286 } else { 287 channel.position(0); 288 fsWrite.writeFilesystem(Channels.newOutputStream(channel)); 289 channel.truncate(channel.position()); 290 } 291 } 292}