/* * titl - Tools for iTunes Libraries * Copyright (C) 2008-2011 Joseph Walton * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ package org.kafsemo.titl; import static org.kafsemo.titl.Util.assertEquals; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.DataOutput; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.security.GeneralSecurityException; import java.util.Arrays; import java.util.zip.Deflater; import java.util.zip.DeflaterInputStream; import java.util.zip.Inflater; import java.util.zip.InflaterInputStream; import java.util.zip.ZipException; import javax.crypto.Cipher; import javax.crypto.spec.SecretKeySpec; public class Hdfm { public final String version; public final int unknown; final byte[] headerRemainder; public final byte[] fileData; public Boolean compressContents; private Hdfm(String version, int unknown, byte[] headerRemainder, byte[] fileData, Boolean compressContents) { this.version = version; this.unknown = unknown; this.headerRemainder = headerRemainder; this.fileData = fileData; this.compressContents = compressContents; } // Byte Length Comment // ----------------------- // 0 4 'hdfm' // 4 4 L = header length // 8 4 file length ? // 12 4 ? // 13 1 N = length of version string // 14 N application version string // 14+N L-N-17 ? public static Hdfm read(Input di, long fileLength) throws IOException, ItlException { int hdr = di.readInt(); assertEquals("hdfm", Util.toString(hdr)); int hl = di.readInt(); int fl = di.readInt(); if (fileLength != fl) { throw new IOException("Disk file is " + fileLength + " but header claims " + fl); } int unknown = di.readInt(); int vsl = di.readUnsignedByte(); byte[] avs = new byte[vsl]; di.readFully(avs); String version = new String(avs, "us-ascii"); int consumed = vsl + 17; byte[] headerRemainder = new byte[hl - consumed]; di.readFully(headerRemainder); consumed += headerRemainder.length; if (hl != consumed) { throw new IOException("Header claims to be " + hl + " bytes but read " + consumed); } byte[] restOfFile = new byte[(int)fileLength - consumed]; di.readFully(restOfFile); byte[] decrypted = new byte[restOfFile.length]; /* Decrypt */ decrypted = crypt(version, restOfFile, Cipher.DECRYPT_MODE); /* Unzip (aka inflate, decompress...) */ byte[] inflated = inflate(decrypted); /* If inflate() returned the exact same array, that means the unzip failed, so we should assume that the compression shouldn't be used for this ITL file. */ boolean useCompression = !Arrays.equals(decrypted, inflated); return new Hdfm(version, unknown, headerRemainder, inflated, useCompression); } /** * hdfm chunks occur inline in 10.0, for some reason. * * @param di * @param length * @param consumed * @return * @throws IOException * @throws ItlException */ public static Hdfm readInline(Input di, int length, int consumed) throws IOException, ItlException { int hl = di.readInt(); if (hl != 0) { throw new IOException("Expected zero for inline HDFM length (was " + hl + ")"); } int fl = di.readInt(); int unknown = di.readInt(); int vsl = di.readUnsignedByte(); byte[] avs = new byte[vsl]; di.readFully(avs); String version = new String(avs, "us-ascii"); consumed += vsl + 13; byte[] headerRemainder = new byte[length - consumed]; di.readFully(headerRemainder); consumed += headerRemainder.length; if (consumed != length) { throw new IOException("Expected to read " + length + " bytes but read " + consumed); } return new Hdfm(version, unknown, headerRemainder, null, false); } /** * Obfuscation description from * <a href="http://search.cpan.org/src/BDFOY/Mac-iTunes-0.90/examples/crypt-rijndael.pl">this sample</a>. * * @param orig * @param mode * @return * @throws UnsupportedEncodingException * @throws ItlException */ private static byte[] crypt(String version, byte[] orig, int mode) throws UnsupportedEncodingException, ItlException { byte[] res = new byte[orig.length]; /* Decrypt */ try { byte[] rawKey = "BHUILuilfghuila3".getBytes("us-ascii"); SecretKeySpec skeySpec = new SecretKeySpec(rawKey, "AES"); Cipher cip = Cipher.getInstance("AES/ECB/NoPadding"); cip.init(mode, skeySpec); int encryptedLength = orig.length; if (ITunesVersion.isAtLeast(version, 10)) { encryptedLength = Math.min(encryptedLength, 102400); } encryptedLength -= encryptedLength % 16; int x = orig.length - encryptedLength; byte[] result = cip.doFinal(orig, 0, encryptedLength); System.arraycopy(result, 0, res, 0, result.length); System.arraycopy(orig, result.length, res, result.length, x); } catch (GeneralSecurityException gse) { if (mode == Cipher.DECRYPT_MODE) { throw new ItlException("Unable to decrypt library", gse); } else if (mode == Cipher.ENCRYPT_MODE) { throw new ItlException("Unable to encrypt library", gse); } else { throw new ItlException("Unable to perform operation", gse); } } return res; } static byte[] inflate(byte[] orig) throws ItlException, ZipException { /* Check for a zlib flag byte; 0x78 => 32k window, deflate */ boolean probablyCompressed = (orig.length >= 1 && orig[0] == 0x78); byte[] inflated = null; try { InflaterInputStream isInflater = new InflaterInputStream(new ByteArrayInputStream(orig), new Inflater()); ByteArrayOutputStream osDecompressed = new ByteArrayOutputStream(orig.length); inflated = new byte[orig.length]; int iDecompressed; while(true) { iDecompressed = isInflater.read(inflated, 0, orig.length); if (iDecompressed == -1) break; osDecompressed.write(inflated, 0, iDecompressed); } inflated = osDecompressed.toByteArray(); osDecompressed.close(); isInflater.close(); } catch (ZipException ze) { if (probablyCompressed) { throw ze; } // If a ZipException occurs, it's probably because "orig" isn't actually compressed data, // because it's from an earlier version of iTunes. // So since there's nothing to decompress, just return the array that was passed in, unchanged. return orig; } catch (IOException ioe) { throw new ItlException("Error when unzipping the file contents", ioe); } return inflated; } private static byte[] deflate(byte[] orig) throws ItlException { try { DeflaterInputStream isDeflater = new DeflaterInputStream(new ByteArrayInputStream(orig), new Deflater()); ByteArrayOutputStream osCompressed = new ByteArrayOutputStream(orig.length); byte[] deflated = new byte[orig.length]; int iCompressed; while(true) { iCompressed = isDeflater.read(deflated, 0, orig.length); if (iCompressed == -1) break; osCompressed.write(deflated, 0, iCompressed); } deflated = osCompressed.toByteArray(); osCompressed.close(); isDeflater.close(); return deflated; } catch (IOException ioe) { throw new ItlException("Error when zipping the file contents", ioe); } } public void write(DataOutput o) throws IllegalArgumentException, IOException, ItlException { write(o, fileData); } public void write(DataOutput o, byte[] dat) throws IllegalArgumentException, IOException, ItlException { if (this.compressContents) { /* If the contents were zipped before, we should zip them now, before encrypting and then writing to the file */ dat = deflate(dat); } /* Write the header */ byte[] ba = version.getBytes("us-ascii"); assert ba.length < 256; o.writeInt(Util.fromString("hdfm")); int hl = 17 + headerRemainder.length + ba.length; o.writeInt(hl); int fileLength = hl + dat.length; o.writeInt(fileLength); o.writeInt(unknown); o.writeByte(ba.length); o.write(ba); o.write(headerRemainder); /* Encode and write the data */ byte[] encrypted = crypt(version, dat, Cipher.ENCRYPT_MODE); o.write(encrypted); } }