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