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.io.RandomAccessFile; 028import java.nio.ByteBuffer; 029import java.nio.ByteOrder; 030import java.nio.channels.Channels; 031import java.nio.channels.SeekableByteChannel; 032import java.security.MessageDigest; 033import java.util.ArrayList; 034import java.util.List; 035import java.util.Map; 036import java.util.NoSuchElementException; 037import java.util.TreeMap; 038 039import org.apache.poi.poifs.filesystem.DocumentEntry; 040import org.apache.poi.poifs.filesystem.DocumentInputStream; 041import org.apache.poi.poifs.filesystem.DocumentNode; 042import org.apache.poi.poifs.filesystem.Entry; 043import org.apache.poi.poifs.filesystem.POIFSDocument; 044import org.apache.poi.poifs.filesystem.POIFSFileSystem; 045import org.apache.poi.poifs.property.DirectoryProperty; 046import org.apache.poi.poifs.property.DocumentProperty; 047import org.apache.poi.poifs.property.Property; 048import org.apache.poi.util.IOUtils; 049import org.bouncycastle.asn1.ASN1Object; 050import org.bouncycastle.asn1.DERNull; 051import org.bouncycastle.asn1.x509.AlgorithmIdentifier; 052import org.bouncycastle.asn1.x509.DigestInfo; 053import org.bouncycastle.cms.CMSSignedData; 054 055import net.jsign.DigestAlgorithm; 056import net.jsign.Signable; 057import net.jsign.SignatureUtils; 058import net.jsign.asn1.authenticode.AuthenticodeObjectIdentifiers; 059import net.jsign.asn1.authenticode.SpcAttributeTypeAndOptionalValue; 060import net.jsign.asn1.authenticode.SpcIndirectDataContent; 061import net.jsign.asn1.authenticode.SpcSipInfo; 062import net.jsign.asn1.authenticode.SpcUuid; 063 064import static org.apache.poi.poifs.common.POIFSConstants.*; 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 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 if (file.length() < 8) { 105 return false; 106 } 107 try (DataInputStream in = new DataInputStream(new FileInputStream(file))) { 108 return in.readLong() == MSI_HEADER; 109 } 110 } 111 112 /** 113 * Create a MSIFile from the specified file. 114 * 115 * @param file the file to open 116 * @throws IOException if an I/O error occurs 117 */ 118 public MSIFile(File file) throws IOException { 119 this.file = file; 120 try { 121 this.fsRead = new POIFSFileSystem(file, true); 122 this.fsWrite = new POIFSFileSystem(file, false); 123 } catch (IndexOutOfBoundsException | IllegalStateException | ClassCastException e) { 124 throw new IOException("MSI file format error", e); 125 } 126 } 127 128 /** 129 * Create a MSIFile from the specified channel. 130 * 131 * @param channel the channel to read the file from 132 * @throws IOException if an I/O error occurs 133 */ 134 public MSIFile(final SeekableByteChannel channel) throws IOException { 135 this.channel = channel; 136 InputStream in = new FilterInputStream(Channels.newInputStream(channel)) { 137 public void close() { } 138 }; 139 this.fsRead = new POIFSFileSystem(in); 140 this.fsWrite = fsRead; 141 } 142 143 /** 144 * Closes the file 145 * 146 * @throws IOException if an I/O error occurs 147 */ 148 public void close() throws IOException { 149 try (POIFSFileSystem fsRead = this.fsRead; POIFSFileSystem fsWrite = this.fsWrite; SeekableByteChannel channel = this.channel) { 150 // do nothing 151 } 152 } 153 154 /** 155 * Tells if the MSI file has an extended signature (MsiDigitalSignatureEx) 156 * containing a hash of the streams metadata (name, size, date). 157 * 158 * @return <code>true</code> if the file has a MsiDigitalSignatureEx stream, <code>false</code> otherwise 159 */ 160 public boolean hasExtendedSignature() { 161 try { 162 fsRead.getRoot().getEntry(MSI_DIGITAL_SIGNATURE_EX_ENTRY_NAME); 163 return true; 164 } catch (FileNotFoundException e) { 165 return false; 166 } 167 } 168 169 @Override 170 public byte[] computeDigest(DigestAlgorithm digestAlgorithm) throws IOException { 171 MessageDigest digest = digestAlgorithm.getMessageDigest(); 172 173 try { 174 // hash the MsiDigitalSignatureEx entry if there is one 175 if (fsRead.getRoot().hasEntry(MSI_DIGITAL_SIGNATURE_EX_ENTRY_NAME)) { 176 Entry msiDigitalSignatureExEntry = fsRead.getRoot().getEntry(MSI_DIGITAL_SIGNATURE_EX_ENTRY_NAME); 177 POIFSDocument msiDigitalSignatureExDocument = new POIFSDocument((DocumentNode) msiDigitalSignatureExEntry); 178 updateDigest(digest, msiDigitalSignatureExDocument); 179 } 180 181 updateDigest(digest, fsRead.getPropertyTable().getRoot()); 182 183 return digest.digest(); 184 } catch (IndexOutOfBoundsException | IllegalArgumentException | IllegalStateException | NoSuchElementException | ClassCastException e) { 185 throw new IOException("MSI file format error", e); 186 } 187 } 188 189 private void updateDigest(MessageDigest digest, DirectoryProperty node) { 190 Map<MSIStreamName, Property> sortedEntries = new TreeMap<>(); 191 for (Property child : node) { 192 sortedEntries.put(new MSIStreamName(child.getName()), child); 193 } 194 195 for (Property property : sortedEntries.values()) { 196 if (!property.isDirectory()) { 197 String name = new MSIStreamName(property.getName()).decode(); 198 if (name.equals(DIGITAL_SIGNATURE_ENTRY_NAME) || name.equals(MSI_DIGITAL_SIGNATURE_EX_ENTRY_NAME)) { 199 continue; 200 } 201 202 POIFSDocument document = new POIFSDocument((DocumentProperty) property, fsRead); 203 updateDigest(digest, document); 204 } else { 205 updateDigest(digest, (DirectoryProperty) property); 206 } 207 } 208 209 // hash the package ClassID, in serialized form 210 byte[] classId = new byte[16]; 211 node.getStorageClsid().write(classId, 0); 212 digest.update(classId); 213 } 214 215 private void updateDigest(MessageDigest digest, POIFSDocument document) { 216 long remaining = document.getSize(); 217 for (ByteBuffer buffer : document) { 218 int size = buffer.remaining(); 219 buffer.limit(buffer.position() + (int) Math.min(remaining, size)); 220 digest.update(buffer); 221 remaining -= size; 222 } 223 } 224 225 @Override 226 public ASN1Object createIndirectData(DigestAlgorithm digestAlgorithm) throws IOException { 227 AlgorithmIdentifier algorithmIdentifier = new AlgorithmIdentifier(digestAlgorithm.oid, DERNull.INSTANCE); 228 DigestInfo digestInfo = new DigestInfo(algorithmIdentifier, computeDigest(digestAlgorithm)); 229 230 SpcUuid uuid = new SpcUuid("F1100C00-0000-0000-C000-000000000046"); 231 SpcAttributeTypeAndOptionalValue data = new SpcAttributeTypeAndOptionalValue(AuthenticodeObjectIdentifiers.SPC_SIPINFO_OBJID, new SpcSipInfo(1, uuid)); 232 233 return new SpcIndirectDataContent(data, digestInfo); 234 } 235 236 @Override 237 public List<CMSSignedData> getSignatures() throws IOException { 238 try { 239 DocumentEntry digitalSignature = (DocumentEntry) fsRead.getRoot().getEntry(DIGITAL_SIGNATURE_ENTRY_NAME); 240 if (digitalSignature != null) { 241 byte[] signatureBytes = IOUtils.toByteArray(new DocumentInputStream(digitalSignature)); 242 243 return SignatureUtils.getSignatures(signatureBytes); 244 } 245 } catch (FileNotFoundException e) { 246 } 247 248 return new ArrayList<>(); 249 } 250 251 @Override 252 public void setSignature(CMSSignedData signature) throws IOException { 253 if (signature != null) { 254 byte[] signatureBytes = signature.toASN1Structure().getEncoded("DER"); 255 try { 256 fsWrite.getRoot().createOrUpdateDocument(DIGITAL_SIGNATURE_ENTRY_NAME, new ByteArrayInputStream(signatureBytes)); 257 } catch (IndexOutOfBoundsException e) { 258 throw new IOException("MSI file format error", e); 259 } 260 } else { 261 // remove the signature 262 if (fsWrite.getRoot().hasEntry(DIGITAL_SIGNATURE_ENTRY_NAME)) { 263 fsWrite.getRoot().getEntry(DIGITAL_SIGNATURE_ENTRY_NAME).delete(); 264 } 265 if (fsWrite.getRoot().hasEntry(MSI_DIGITAL_SIGNATURE_EX_ENTRY_NAME)) { 266 fsWrite.getRoot().getEntry(MSI_DIGITAL_SIGNATURE_EX_ENTRY_NAME).delete(); 267 } 268 } 269 } 270 271 @Override 272 public void save() throws IOException { 273 // get the number of directory sectors to be written in the header to work around https://bz.apache.org/bugzilla/show_bug.cgi?id=66590 274 ByteBuffer directorySectorsCount = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN); 275 directorySectorsCount.putInt(fsWrite.getPropertyTable().countBlocks()).flip(); 276 int version = fsWrite.getBigBlockSize() == SMALLER_BIG_BLOCK_SIZE ? 3 : 4; 277 278 if (channel == null) { 279 fsWrite.writeFilesystem(); 280 281 // update the number of directory sectors in the header 282 if (version == 4) { 283 fsWrite.close(); 284 try (RandomAccessFile in = new RandomAccessFile(file, "rw")) { 285 in.seek(0x28); 286 in.write(directorySectorsCount.array()); 287 } 288 try { 289 fsWrite = new POIFSFileSystem(file, false); 290 } catch (IndexOutOfBoundsException e) { 291 throw new IOException("MSI file format error", e); 292 } 293 } 294 295 fsRead.close(); 296 try { 297 fsRead = new POIFSFileSystem(file, true); 298 } catch (IndexOutOfBoundsException e) { 299 throw new IOException("MSI file format error", e); 300 } 301 } else { 302 channel.position(0); 303 fsWrite.writeFilesystem(Channels.newOutputStream(channel)); 304 channel.truncate(channel.position()); 305 306 // update the number of directory sectors in the header 307 if (version == 4) { 308 channel.position(0x28); 309 channel.write(directorySectorsCount); 310 } 311 } 312 } 313}