diff options
author | Maxim Valyanskiy <maxcom@apache.org> | 2011-05-10 10:38:17 +0000 |
---|---|---|
committer | Maxim Valyanskiy <maxcom@apache.org> | 2011-05-10 10:38:17 +0000 |
commit | 138bd6f94c47adb06d109b5b1a342a0b37547c0c (patch) | |
tree | 9bb911d648f98facb242f1839bd054c056ef0a6c | |
parent | e94feeee12d4356cf5e46b44894032b64f6d0110 (diff) | |
download | poi-138bd6f94c47adb06d109b5b1a342a0b37547c0c.tar.gz poi-138bd6f94c47adb06d109b5b1a342a0b37547c0c.zip |
bug#51165: Add support for OOXML Agile Encryption
git-svn-id: https://svn.apache.org/repos/asf/poi/trunk@1101397 13f79535-47bb-0310-9956-ffa450edef68
-rw-r--r-- | build.xml | 9 | ||||
-rw-r--r-- | src/documentation/content/xdocs/encryption.xml | 84 | ||||
-rw-r--r-- | src/java/org/apache/poi/poifs/crypt/AgileDecryptor.java | 244 | ||||
-rw-r--r-- | src/java/org/apache/poi/poifs/crypt/Decryptor.java | 170 | ||||
-rw-r--r-- | src/java/org/apache/poi/poifs/crypt/EcmaDecryptor.java | 132 | ||||
-rw-r--r-- | src/java/org/apache/poi/poifs/crypt/EncryptionHeader.java | 88 | ||||
-rw-r--r-- | src/java/org/apache/poi/poifs/crypt/EncryptionInfo.java | 31 | ||||
-rw-r--r-- | src/java/org/apache/poi/poifs/crypt/EncryptionVerifier.java | 114 | ||||
-rw-r--r-- | src/testcases/org/apache/poi/poifs/crypt/AllPOIFSCryptoTests.java | 37 | ||||
-rw-r--r-- | src/testcases/org/apache/poi/poifs/crypt/TestDecryptor.java (renamed from src/testcases/org/apache/poi/poifs/crypt/DecryptorTest.java) | 21 | ||||
-rw-r--r-- | src/testcases/org/apache/poi/poifs/crypt/TestEncryptionInfo.java (renamed from src/testcases/org/apache/poi/poifs/crypt/EncryptionInfoTest.java) | 2 | ||||
-rw-r--r-- | test-data/poifs/protected_agile.docx | bin | 0 -> 19456 bytes |
12 files changed, 789 insertions, 143 deletions
@@ -122,6 +122,9 @@ under the License. <property name="main.commons-logging.jar" location="${main.lib}/commons-logging-1.1.jar"/> <property name="main.commons-logging.url" value="${repository.m2}/maven2/commons-logging/commons-logging/1.1/commons-logging-1.1.jar"/> + <property name="main.commons-codec.jar" location="${main.lib}/commons-codec-1.5.jar"/> + <property name="main.commons-codec.url" + value="${repository.m2}/maven2/commons-codec/commons-codec/1.5/commons-codec-1.5.jar"/> <property name="main.log4j.jar" location="${main.lib}/log4j-1.2.13.jar"/> <property name="main.log4j.url" value="${repository.m2}/maven2/log4j/log4j/1.2.13/log4j-1.2.13.jar"/> <property name="main.junit.jar" location="${main.lib}/junit-3.8.1.jar"/> @@ -166,6 +169,7 @@ under the License. <path id="main.classpath"> <pathelement location="${main.commons-logging.jar}"/> + <pathelement location="${main.commons-codec.jar}"/> <pathelement location="${main.log4j.jar}"/> <pathelement location="${main.junit.jar}"/> </path> @@ -295,6 +299,7 @@ under the License. <or> <and> <available file="${main.commons-logging.jar}"/> + <available file="${main.commons-codec.jar}"/> <available file="${main.log4j.jar}"/> <available file="${main.junit.jar}"/> <available file="${main.ant.jar}"/> @@ -312,6 +317,10 @@ under the License. <param name="destfile" value="${main.commons-logging.jar}"/> </antcall> <antcall target="downloadfile"> + <param name="sourcefile" value="${main.commons-codec.url}"/> + <param name="destfile" value="${main.commons-codec.jar}"/> + </antcall> + <antcall target="downloadfile"> <param name="sourcefile" value="${main.log4j.url}"/> <param name="destfile" value="${main.log4j.jar}"/> </antcall> diff --git a/src/documentation/content/xdocs/encryption.xml b/src/documentation/content/xdocs/encryption.xml new file mode 100644 index 0000000000..61a2a200dd --- /dev/null +++ b/src/documentation/content/xdocs/encryption.xml @@ -0,0 +1,84 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + ==================================================================== + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + ==================================================================== +--> +<!DOCTYPE document PUBLIC "-//APACHE//DTD Documentation V1.3//EN" "./dtd/document-v13.dtd"> + + +<document> + <header> + <title>Apache POI - Encryption support</title> + <authors> + <person id="maxcom" name="Maxim Valyanskiy" email="maxcom@apache.org"/> + </authors> + </header> + + <body> + <section><title>Overview</title> + <p>Apache POI contains support for reading few variants of encrypted office files: </p> + <ul> + <li>XLS - RC4 Encryption</li> + <li>XML-based formats (XLSX, DOCX and etc) - AES Encryption</li> + </ul> + + <p>Some "write-protected" files are encrypted with build-in password, POI can read that files too.</p> + </section> + + <section><title>XLS</title> + <p>When HSSF receive encrypted file, it tries to decode it with MSOffice build-in password. + Use static method setCurrentUserPassword(String password) of org.apache.poi.hssf.record.crypto.Biff8EncryptionKey to + set password. It sets thread local variable. Do not forget to reset it to null after text extraction. + </p> + </section> + + <section><title>XML-based formats</title> + <p>XML-based formats are stored in OLE-package stream "EncryptedPackage". Use org.apache.poi.poifs.crypt.Decryptor + to decode file:</p> + + <source> +EncryptionInfo info = new EncryptionInfo(filesystem); +Decryptor d = new Decryptor(info); + +try { + if (!d.verifyPassword(password)) { + throw new RuntimeException("Unable to process: document is encrypted"); + } + + InputStream dataStream = d.getDataStream(filesystem); + + // parse dataStream + +} catch (GeneralSecurityException ex) { + throw new RuntimeException("Unable to process encrypted document", ex); +} + </source> + + <p>If you want to read file encrypted with build-in password, use Decryptor.DEFAULT_PASSWORD.</p> + </section> + </body> + + <footer> + <legal> + Copyright (c) @year@ The Apache Software Foundation. All rights reserved. + </legal> + </footer> +</document> + + + + diff --git a/src/java/org/apache/poi/poifs/crypt/AgileDecryptor.java b/src/java/org/apache/poi/poifs/crypt/AgileDecryptor.java new file mode 100644 index 0000000000..17ef47bb83 --- /dev/null +++ b/src/java/org/apache/poi/poifs/crypt/AgileDecryptor.java @@ -0,0 +1,244 @@ +/* ==================================================================== + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +==================================================================== */ +package org.apache.poi.poifs.crypt; + +import java.util.Arrays; +import java.io.IOException; +import java.io.InputStream; +import java.io.FilterInputStream; +import java.io.ByteArrayInputStream; +import java.security.MessageDigest; +import java.security.GeneralSecurityException; +import java.security.NoSuchAlgorithmException; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; +import org.apache.poi.poifs.filesystem.DirectoryNode; +import org.apache.poi.EncryptedDocumentException; + +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import javax.crypto.spec.IvParameterSpec; + +import org.apache.poi.poifs.filesystem.DirectoryNode; +import org.apache.poi.poifs.filesystem.DocumentInputStream; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; +import org.apache.poi.util.LittleEndian; + +/** + * @author Gary King + */ +public class AgileDecryptor extends Decryptor { + + private final EncryptionInfo _info; + private SecretKey _secretKey; + + private static final byte[] kVerifierInputBlock; + private static final byte[] kHashedVerifierBlock; + private static final byte[] kCryptoKeyBlock; + + static { + kVerifierInputBlock = + new byte[] { (byte)0xfe, (byte)0xa7, (byte)0xd2, (byte)0x76, + (byte)0x3b, (byte)0x4b, (byte)0x9e, (byte)0x79 }; + kHashedVerifierBlock = + new byte[] { (byte)0xd7, (byte)0xaa, (byte)0x0f, (byte)0x6d, + (byte)0x30, (byte)0x61, (byte)0x34, (byte)0x4e }; + kCryptoKeyBlock = + new byte[] { (byte)0x14, (byte)0x6e, (byte)0x0b, (byte)0xe7, + (byte)0xab, (byte)0xac, (byte)0xd0, (byte)0xd6 }; + } + + public boolean verifyPassword(String password) throws GeneralSecurityException { + EncryptionVerifier verifier = _info.getVerifier(); + int algorithm = verifier.getAlgorithm(); + int mode = verifier.getCipherMode(); + + byte[] pwHash = hashPassword(_info, password); + byte[] iv = generateIv(algorithm, verifier.getSalt(), null); + + SecretKey skey; + skey = new SecretKeySpec(generateKey(pwHash, kVerifierInputBlock), "AES"); + Cipher cipher = getCipher(algorithm, mode, skey, iv); + byte[] verifierHashInput = cipher.doFinal(verifier.getVerifier()); + + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + byte[] trimmed = new byte[verifier.getSalt().length]; + System.arraycopy(verifierHashInput, 0, trimmed, 0, trimmed.length); + byte[] hashedVerifier = sha1.digest(trimmed); + + skey = new SecretKeySpec(generateKey(pwHash, kHashedVerifierBlock), "AES"); + iv = generateIv(algorithm, verifier.getSalt(), null); + cipher = getCipher(algorithm, mode, skey, iv); + byte[] verifierHash = cipher.doFinal(verifier.getVerifierHash()); + trimmed = new byte[hashedVerifier.length]; + System.arraycopy(verifierHash, 0, trimmed, 0, trimmed.length); + + if (Arrays.equals(trimmed, hashedVerifier)) { + skey = new SecretKeySpec(generateKey(pwHash, kCryptoKeyBlock), "AES"); + iv = generateIv(algorithm, verifier.getSalt(), null); + cipher = getCipher(algorithm, mode, skey, iv); + byte[] inter = cipher.doFinal(verifier.getEncryptedKey()); + byte[] keyspec = new byte[_info.getHeader().getKeySize() / 8]; + System.arraycopy(inter, 0, keyspec, 0, keyspec.length); + _secretKey = new SecretKeySpec(keyspec, "AES"); + return true; + } else { + return false; + } + } + + public InputStream getDataStream(DirectoryNode dir) throws IOException, GeneralSecurityException { + DocumentInputStream dis = dir.createDocumentInputStream("EncryptedPackage"); + long size = dis.readLong(); + return new ChunkedCipherInputStream(dis, size); + } + + protected AgileDecryptor(EncryptionInfo info) { + _info = info; + } + + private class ChunkedCipherInputStream extends InputStream { + private int _lastIndex = 0; + private long _pos = 0; + private final long _size; + private final DocumentInputStream _stream; + private byte[] _chunk; + private Cipher _cipher; + + public ChunkedCipherInputStream(DocumentInputStream stream, long size) + throws GeneralSecurityException { + _size = size; + _stream = stream; + _cipher = getCipher(_info.getHeader().getAlgorithm(), + _info.getHeader().getCipherMode(), + _secretKey, _info.getHeader().getKeySalt()); + } + + public int read() throws IOException { + byte[] b = new byte[1]; + if (read(b) == 1) + return b[0]; + return -1; + } + + public int read(byte[] b) throws IOException { + return read(b, 0, b.length); + } + + public int read(byte[] b, int off, int len) throws IOException { + int total = 0; + + while (len > 0) { + if (_chunk == null) { + try { + _chunk = nextChunk(); + } catch (GeneralSecurityException e) { + throw new EncryptedDocumentException(e.getMessage()); + } + } + int count = (int)(4096L - (_pos & 0xfff)); + count = Math.min(available(), Math.min(count, len)); + System.arraycopy(_chunk, (int)(_pos & 0xfff), b, off, count); + off += count; + len -= count; + _pos += count; + if ((_pos & 0xfff) == 0) + _chunk = null; + total += count; + } + + return total; + } + + public long skip(long n) throws IOException { + long start = _pos; + long skip = Math.min(available(), n); + + if ((((_pos + skip) ^ start) & ~0xfff) != 0) + _chunk = null; + _pos += skip; + return skip; + } + + public int available() throws IOException { return (int)(_size - _pos); } + public void close() throws IOException { _stream.close(); } + public boolean markSupported() { return false; } + + private byte[] nextChunk() throws GeneralSecurityException, IOException { + int index = (int)(_pos >> 12); + byte[] blockKey = new byte[4]; + LittleEndian.putInt(blockKey, index); + byte[] iv = generateIv(_info.getHeader().getAlgorithm(), + _info.getHeader().getKeySalt(), blockKey); + _cipher.init(Cipher.DECRYPT_MODE, _secretKey, new IvParameterSpec(iv)); + if (_lastIndex != index) + _stream.skip((index - _lastIndex) << 12); + + byte[] block = new byte[Math.min(_stream.available(), 4096)]; + _stream.readFully(block); + _lastIndex = index + 1; + return _cipher.doFinal(block); + } + } + + private Cipher getCipher(int algorithm, int mode, SecretKey key, byte[] vec) + throws GeneralSecurityException { + String name = null; + String chain = null; + + if (algorithm == EncryptionHeader.ALGORITHM_AES_128 || + algorithm == EncryptionHeader.ALGORITHM_AES_192 || + algorithm == EncryptionHeader.ALGORITHM_AES_256) + name = "AES"; + + if (mode == EncryptionHeader.MODE_CBC) + chain = "CBC"; + else if (mode == EncryptionHeader.MODE_CFB) + chain = "CFB"; + + Cipher cipher = Cipher.getInstance(name + "/" + chain + "/NoPadding"); + IvParameterSpec iv = new IvParameterSpec(vec); + cipher.init(Cipher.DECRYPT_MODE, key, iv); + return cipher; + } + + private byte[] getBlock(int algorithm, byte[] hash) { + byte[] result = new byte[getBlockSize(algorithm)]; + Arrays.fill(result, (byte)0x36); + System.arraycopy(hash, 0, result, 0, Math.min(result.length, hash.length)); + return result; + } + + private byte[] generateKey(byte[] hash, byte[] blockKey) throws NoSuchAlgorithmException { + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + sha1.update(hash); + return getBlock(_info.getVerifier().getAlgorithm(), sha1.digest(blockKey)); + } + + protected byte[] generateIv(int algorithm, byte[] salt, byte[] blockKey) + throws NoSuchAlgorithmException { + + + if (blockKey == null) + return getBlock(algorithm, salt); + + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + sha1.update(salt); + return getBlock(algorithm, sha1.digest(blockKey)); + } +}
\ No newline at end of file diff --git a/src/java/org/apache/poi/poifs/crypt/Decryptor.java b/src/java/org/apache/poi/poifs/crypt/Decryptor.java index e97f41b41d..fb5b11f7b3 100644 --- a/src/java/org/apache/poi/poifs/crypt/Decryptor.java +++ b/src/java/org/apache/poi/poifs/crypt/Decryptor.java @@ -19,150 +19,74 @@ package org.apache.poi.poifs.crypt; import java.io.IOException; import java.io.InputStream; import java.io.UnsupportedEncodingException; -import java.security.GeneralSecurityException; import java.security.MessageDigest; +import java.security.GeneralSecurityException; import java.security.NoSuchAlgorithmException; -import java.util.Arrays; - -import javax.crypto.Cipher; -import javax.crypto.CipherInputStream; -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; - -import org.apache.poi.poifs.filesystem.DirectoryNode; -import org.apache.poi.poifs.filesystem.DocumentInputStream; import org.apache.poi.poifs.filesystem.NPOIFSFileSystem; import org.apache.poi.poifs.filesystem.POIFSFileSystem; +import org.apache.poi.poifs.filesystem.DirectoryNode; +import org.apache.poi.EncryptedDocumentException; import org.apache.poi.util.LittleEndian; -/** - * @author Maxim Valyanskiy - */ -public class Decryptor { +public abstract class Decryptor { public static final String DEFAULT_PASSWORD="VelvetSweatshop"; - private final EncryptionInfo info; - private byte[] passwordHash; + public abstract InputStream getDataStream(DirectoryNode dir) + throws IOException, GeneralSecurityException; + + public abstract boolean verifyPassword(String password) + throws GeneralSecurityException; + + public static Decryptor getInstance(EncryptionInfo info) { + int major = info.getVersionMajor(); + int minor = info.getVersionMinor(); - public Decryptor(EncryptionInfo info) { - this.info = info; + if (major == 4 && minor == 4) + return new AgileDecryptor(info); + else if (minor == 2 && (major == 3 || major == 4)) + return new EcmaDecryptor(info); + else + throw new EncryptedDocumentException("Unsupported version"); } - private void generatePasswordHash(String password) throws NoSuchAlgorithmException { + public InputStream getDataStream(NPOIFSFileSystem fs) throws IOException, GeneralSecurityException { + return getDataStream(fs.getRoot()); + } + + public InputStream getDataStream(POIFSFileSystem fs) throws IOException, GeneralSecurityException { + return getDataStream(fs.getRoot()); + } + + protected static int getBlockSize(int algorithm) { + switch (algorithm) { + case EncryptionHeader.ALGORITHM_AES_128: return 16; + case EncryptionHeader.ALGORITHM_AES_192: return 24; + case EncryptionHeader.ALGORITHM_AES_256: return 32; + } + throw new EncryptedDocumentException("Unknown block size"); + } + + protected byte[] hashPassword(EncryptionInfo info, + String password) throws NoSuchAlgorithmException { MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); - - byte[] passwordBytes; + byte[] bytes; try { - passwordBytes = password.getBytes("UTF-16LE"); - } catch(UnsupportedEncodingException e) { - throw new RuntimeException("Your JVM is broken - UTF16 not found!"); + bytes = password.getBytes("UTF-16LE"); + } catch (UnsupportedEncodingException e) { + throw new EncryptedDocumentException("UTF16 not supported"); } sha1.update(info.getVerifier().getSalt()); - byte[] hash = sha1.digest(passwordBytes); - + byte[] hash = sha1.digest(bytes); byte[] iterator = new byte[4]; - for (int i = 0; i<50000; i++) { - sha1.reset(); + for (int i = 0; i < info.getVerifier().getSpinCount(); i++) { + sha1.reset(); LittleEndian.putInt(iterator, i); sha1.update(iterator); hash = sha1.digest(hash); } - passwordHash = hash; - } - - private byte[] generateKey(int block) throws NoSuchAlgorithmException { - MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); - - sha1.update(passwordHash); - byte[] blockValue = new byte[4]; - LittleEndian.putInt(blockValue, block); - byte[] finalHash = sha1.digest(blockValue); - - int requiredKeyLength = info.getHeader().getKeySize()/8; - - byte[] buff = new byte[64]; - - Arrays.fill(buff, (byte) 0x36); - - for (int i=0; i<finalHash.length; i++) { - buff[i] = (byte) (buff[i] ^ finalHash[i]); - } - - sha1.reset(); - byte[] x1 = sha1.digest(buff); - - Arrays.fill(buff, (byte) 0x5c); - for (int i=0; i<finalHash.length; i++) { - buff[i] = (byte) (buff[i] ^ finalHash[i]); - } - - sha1.reset(); - byte[] x2 = sha1.digest(buff); - - byte[] x3 = new byte[x1.length + x2.length]; - System.arraycopy(x1, 0, x3, 0, x1.length); - System.arraycopy(x2, 0, x3, x1.length, x2.length); - - return truncateOrPad(x3, requiredKeyLength); - } - - public boolean verifyPassword(String password) throws GeneralSecurityException { - generatePasswordHash(password); - - Cipher cipher = getCipher(); - - byte[] verifier = cipher.doFinal(info.getVerifier().getVerifier()); - - MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); - byte[] calcVerifierHash = sha1.digest(verifier); - - byte[] verifierHash = truncateOrPad(cipher.doFinal(info.getVerifier().getVerifierHash()), calcVerifierHash.length); - - return Arrays.equals(calcVerifierHash, verifierHash); - } - - /** - * Returns a byte array of the requested length, - * truncated or zero padded as needed. - * Behaves like Arrays.copyOf in Java 1.6 - */ - private byte[] truncateOrPad(byte[] source, int length) { - byte[] result = new byte[length]; - System.arraycopy(source, 0, result, 0, Math.min(length, source.length)); - if(length > source.length) { - for(int i=source.length; i<length; i++) { - result[i] = 0; - } - } - return result; - } - - private Cipher getCipher() throws GeneralSecurityException { - byte[] key = generateKey(0); - Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding"); - SecretKey skey = new SecretKeySpec(key, "AES"); - cipher.init(Cipher.DECRYPT_MODE, skey); - - return cipher; - } - - public InputStream getDataStream(POIFSFileSystem fs) throws IOException, GeneralSecurityException { - return getDataStream(fs.getRoot()); - } - - public InputStream getDataStream(NPOIFSFileSystem fs) throws IOException, GeneralSecurityException { - return getDataStream(fs.getRoot()); - } - - @SuppressWarnings("unused") - public InputStream getDataStream(DirectoryNode dir) throws IOException, GeneralSecurityException { - DocumentInputStream dis = dir.createDocumentInputStream("EncryptedPackage"); - - long size = dis.readLong(); - - return new CipherInputStream(dis, getCipher()); + return hash; } -} +}
\ No newline at end of file diff --git a/src/java/org/apache/poi/poifs/crypt/EcmaDecryptor.java b/src/java/org/apache/poi/poifs/crypt/EcmaDecryptor.java new file mode 100644 index 0000000000..441a4335fe --- /dev/null +++ b/src/java/org/apache/poi/poifs/crypt/EcmaDecryptor.java @@ -0,0 +1,132 @@ +/* ==================================================================== + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +==================================================================== */ +package org.apache.poi.poifs.crypt; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; + +import javax.crypto.Cipher; +import javax.crypto.CipherInputStream; +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.apache.poi.poifs.filesystem.DirectoryNode; +import org.apache.poi.poifs.filesystem.DocumentInputStream; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; +import org.apache.poi.util.LittleEndian; + +/** + * @author Maxim Valyanskiy + * @author Gary King + */ +public class EcmaDecryptor extends Decryptor { + private final EncryptionInfo info; + private byte[] passwordHash; + + public EcmaDecryptor(EncryptionInfo info) { + this.info = info; + } + + private byte[] generateKey(int block) throws NoSuchAlgorithmException { + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + + sha1.update(passwordHash); + byte[] blockValue = new byte[4]; + LittleEndian.putInt(blockValue, block); + byte[] finalHash = sha1.digest(blockValue); + + int requiredKeyLength = info.getHeader().getKeySize()/8; + + byte[] buff = new byte[64]; + + Arrays.fill(buff, (byte) 0x36); + + for (int i=0; i<finalHash.length; i++) { + buff[i] = (byte) (buff[i] ^ finalHash[i]); + } + + sha1.reset(); + byte[] x1 = sha1.digest(buff); + + Arrays.fill(buff, (byte) 0x5c); + for (int i=0; i<finalHash.length; i++) { + buff[i] = (byte) (buff[i] ^ finalHash[i]); + } + + sha1.reset(); + byte[] x2 = sha1.digest(buff); + + byte[] x3 = new byte[x1.length + x2.length]; + System.arraycopy(x1, 0, x3, 0, x1.length); + System.arraycopy(x2, 0, x3, x1.length, x2.length); + + return truncateOrPad(x3, requiredKeyLength); + } + + public boolean verifyPassword(String password) throws GeneralSecurityException { + passwordHash = hashPassword(info, password); + + Cipher cipher = getCipher(); + + byte[] verifier = cipher.doFinal(info.getVerifier().getVerifier()); + + MessageDigest sha1 = MessageDigest.getInstance("SHA-1"); + byte[] calcVerifierHash = sha1.digest(verifier); + + byte[] verifierHash = truncateOrPad(cipher.doFinal(info.getVerifier().getVerifierHash()), calcVerifierHash.length); + + return Arrays.equals(calcVerifierHash, verifierHash); + } + + /** + * Returns a byte array of the requested length, + * truncated or zero padded as needed. + * Behaves like Arrays.copyOf in Java 1.6 + */ + private byte[] truncateOrPad(byte[] source, int length) { + byte[] result = new byte[length]; + System.arraycopy(source, 0, result, 0, Math.min(length, source.length)); + if(length > source.length) { + for(int i=source.length; i<length; i++) { + result[i] = 0; + } + } + return result; + } + + private Cipher getCipher() throws GeneralSecurityException { + byte[] key = generateKey(0); + Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding"); + SecretKey skey = new SecretKeySpec(key, "AES"); + cipher.init(Cipher.DECRYPT_MODE, skey); + + return cipher; + } + + public InputStream getDataStream(DirectoryNode dir) throws IOException, GeneralSecurityException { + DocumentInputStream dis = dir.createDocumentInputStream("EncryptedPackage"); + + long size = dis.readLong(); + + return new CipherInputStream(dis, getCipher()); + } +} diff --git a/src/java/org/apache/poi/poifs/crypt/EncryptionHeader.java b/src/java/org/apache/poi/poifs/crypt/EncryptionHeader.java index 893f8cc9c5..21dd25e99c 100644 --- a/src/java/org/apache/poi/poifs/crypt/EncryptionHeader.java +++ b/src/java/org/apache/poi/poifs/crypt/EncryptionHeader.java @@ -16,12 +16,19 @@ ==================================================================== */ package org.apache.poi.poifs.crypt; +import org.apache.commons.codec.binary.Base64; import org.apache.poi.poifs.filesystem.DocumentInputStream; import java.io.IOException; +import java.io.ByteArrayInputStream; + +import org.w3c.dom.NamedNodeMap; +import javax.xml.parsers.DocumentBuilderFactory; +import org.apache.poi.EncryptedDocumentException; /** * @author Maxim Valyanskiy + * @author Gary King */ public class EncryptionHeader { public static final int ALGORITHM_RC4 = 0x6801; @@ -32,7 +39,11 @@ public class EncryptionHeader { public static final int HASH_SHA1 = 0x8004; public static final int PROVIDER_RC4 = 1; - public static final int PROVIDER_AES = 0x18; + public static final int PROVIDER_AES = 0x18; + + public static final int MODE_ECB = 1; + public static final int MODE_CBC = 2; + public static final int MODE_CFB = 3; private final int flags; private final int sizeExtra; @@ -40,6 +51,8 @@ public class EncryptionHeader { private final int hashAlgorithm; private final int keySize; private final int providerType; + private final int cipherMode; + private final byte[] keySalt; private final String cspName; public EncryptionHeader(DocumentInputStream is) throws IOException { @@ -63,8 +76,75 @@ public class EncryptionHeader { builder.append(c); } - cspName = builder.toString(); + cipherMode = MODE_ECB; + keySalt = null; + } + + public EncryptionHeader(String descriptor) throws IOException { + NamedNodeMap keyData; + try { + ByteArrayInputStream is; + is = new ByteArrayInputStream(descriptor.getBytes()); + keyData = DocumentBuilderFactory.newInstance() + .newDocumentBuilder().parse(is) + .getElementsByTagName("keyData").item(0).getAttributes(); + } catch (Exception e) { + throw new EncryptedDocumentException("Unable to parse keyData"); + } + + keySize = Integer.parseInt(keyData.getNamedItem("keyBits") + .getNodeValue()); + flags = 0; + sizeExtra = 0; + cspName = null; + + int blockSize = Integer.parseInt(keyData.getNamedItem("blockSize"). + getNodeValue()); + String cipher = keyData.getNamedItem("cipherAlgorithm").getNodeValue(); + + if ("AES".equals(cipher)) { + providerType = PROVIDER_AES; + if (blockSize == 16) + algorithm = ALGORITHM_AES_128; + else if (blockSize == 24) + algorithm = ALGORITHM_AES_192; + else if (blockSize == 32) + algorithm = ALGORITHM_AES_256; + else + throw new EncryptedDocumentException("Unsupported key length"); + } else { + throw new EncryptedDocumentException("Unsupported cipher"); + } + + String chaining = keyData.getNamedItem("cipherChaining").getNodeValue(); + + if ("ChainingModeCBC".equals(chaining)) + cipherMode = MODE_CBC; + else if ("ChainingModeCFB".equals(chaining)) + cipherMode = MODE_CFB; + else + throw new EncryptedDocumentException("Unsupported chaining mode"); + + String hashAlg = keyData.getNamedItem("hashAlgorithm").getNodeValue(); + int hashSize = Integer.parseInt(keyData.getNamedItem("hashSize") + .getNodeValue()); + + if ("SHA1".equals(hashAlg) && hashSize == 20) + hashAlgorithm = HASH_SHA1; + else + throw new EncryptedDocumentException("Unsupported hash algorithm"); + + String salt = keyData.getNamedItem("saltValue").getNodeValue(); + int saltLength = Integer.parseInt(keyData.getNamedItem("saltSize") + .getNodeValue()); + keySalt = Base64.decodeBase64(salt.getBytes()); + if (keySalt.length != saltLength) + throw new EncryptedDocumentException("Invalid salt length"); + } + + public int getCipherMode() { + return cipherMode; } public int getFlags() { @@ -87,6 +167,10 @@ public class EncryptionHeader { return keySize; } + public byte[] getKeySalt() { + return keySalt; + } + public int getProviderType() { return providerType; } diff --git a/src/java/org/apache/poi/poifs/crypt/EncryptionInfo.java b/src/java/org/apache/poi/poifs/crypt/EncryptionInfo.java index 68ead77428..70dd03799e 100644 --- a/src/java/org/apache/poi/poifs/crypt/EncryptionInfo.java +++ b/src/java/org/apache/poi/poifs/crypt/EncryptionInfo.java @@ -16,15 +16,16 @@ ==================================================================== */ package org.apache.poi.poifs.crypt; +import org.apache.poi.poifs.filesystem.DocumentEntry; import org.apache.poi.poifs.filesystem.DirectoryNode; import org.apache.poi.poifs.filesystem.DocumentInputStream; -import org.apache.poi.poifs.filesystem.NPOIFSFileSystem; import org.apache.poi.poifs.filesystem.POIFSFileSystem; import java.io.IOException; /** * @author Maxim Valyanskiy + * @author Gary King */ public class EncryptionInfo { private final int versionMajor; @@ -37,24 +38,30 @@ public class EncryptionInfo { public EncryptionInfo(POIFSFileSystem fs) throws IOException { this(fs.getRoot()); } - public EncryptionInfo(NPOIFSFileSystem fs) throws IOException { - this(fs.getRoot()); - } public EncryptionInfo(DirectoryNode dir) throws IOException { DocumentInputStream dis = dir.createDocumentInputStream("EncryptionInfo"); - versionMajor = dis.readShort(); versionMinor = dis.readShort(); - encryptionFlags = dis.readInt(); - - int hSize = dis.readInt(); - header = new EncryptionHeader(dis); + encryptionFlags = dis.readInt(); - if (header.getAlgorithm()==EncryptionHeader.ALGORITHM_RC4) { - verifier = new EncryptionVerifier(dis, 20); + if (versionMajor == 4 && versionMinor == 4 && encryptionFlags == 0x40) { + StringBuilder builder = new StringBuilder(); + byte[] xmlDescriptor = new byte[dis.available()]; + dis.read(xmlDescriptor); + for (byte b : xmlDescriptor) + builder.append((char)b); + String descriptor = builder.toString(); + header = new EncryptionHeader(descriptor); + verifier = new EncryptionVerifier(descriptor); } else { - verifier = new EncryptionVerifier(dis, 32); + int hSize = dis.readInt(); + header = new EncryptionHeader(dis); + if (header.getAlgorithm()==EncryptionHeader.ALGORITHM_RC4) { + verifier = new EncryptionVerifier(dis, 20); + } else { + verifier = new EncryptionVerifier(dis, 32); + } } } diff --git a/src/java/org/apache/poi/poifs/crypt/EncryptionVerifier.java b/src/java/org/apache/poi/poifs/crypt/EncryptionVerifier.java index 0eccf338f0..f4028ec78f 100644 --- a/src/java/org/apache/poi/poifs/crypt/EncryptionVerifier.java +++ b/src/java/org/apache/poi/poifs/crypt/EncryptionVerifier.java @@ -16,16 +16,103 @@ ==================================================================== */ package org.apache.poi.poifs.crypt; +import java.io.ByteArrayInputStream; + +import org.apache.commons.codec.binary.Base64; + import org.apache.poi.poifs.filesystem.DocumentInputStream; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.w3c.dom.NamedNodeMap; +import javax.xml.parsers.DocumentBuilderFactory; +import org.apache.poi.EncryptedDocumentException; + /** * @author Maxim Valyanskiy + * @author Gary King */ public class EncryptionVerifier { - private final byte[] salt = new byte[16]; - private final byte[] verifier = new byte[16]; + private final byte[] salt; + private final byte[] verifier; private final byte[] verifierHash; + private final byte[] encryptedKey; private final int verifierHashSize; + private final int spinCount; + private final int algorithm; + private final int cipherMode; + + public EncryptionVerifier(String descriptor) { + NamedNodeMap keyData = null; + try { + ByteArrayInputStream is; + is = new ByteArrayInputStream(descriptor.getBytes()); + NodeList keyEncryptor = DocumentBuilderFactory.newInstance() + .newDocumentBuilder().parse(is) + .getElementsByTagName("keyEncryptor").item(0).getChildNodes(); + for (int i = 0; i < keyEncryptor.getLength(); i++) { + Node node = keyEncryptor.item(i); + if (node.getNodeName().equals("p:encryptedKey")) { + keyData = node.getAttributes(); + break; + } + } + if (keyData == null) + throw new EncryptedDocumentException(""); + } catch (Exception e) { + throw new EncryptedDocumentException("Unable to parse keyEncryptor"); + } + + spinCount = Integer.parseInt(keyData.getNamedItem("spinCount") + .getNodeValue()); + verifier = Base64.decodeBase64(keyData + .getNamedItem("encryptedVerifierHashInput") + .getNodeValue().getBytes()); + salt = Base64.decodeBase64(keyData.getNamedItem("saltValue") + .getNodeValue().getBytes()); + + encryptedKey = Base64.decodeBase64(keyData + .getNamedItem("encryptedKeyValue") + .getNodeValue().getBytes()); + + int saltSize = Integer.parseInt(keyData.getNamedItem("saltSize") + .getNodeValue()); + if (saltSize != salt.length) + throw new EncryptedDocumentException("Invalid salt size"); + + verifierHash = Base64.decodeBase64(keyData + .getNamedItem("encryptedVerifierHashValue") + .getNodeValue().getBytes()); + + int blockSize = Integer.parseInt(keyData.getNamedItem("blockSize") + .getNodeValue()); + + String alg = keyData.getNamedItem("cipherAlgorithm").getNodeValue(); + + if ("AES".equals(alg)) { + if (blockSize == 16) + algorithm = EncryptionHeader.ALGORITHM_AES_128; + else if (blockSize == 24) + algorithm = EncryptionHeader.ALGORITHM_AES_192; + else if (blockSize == 32) + algorithm = EncryptionHeader.ALGORITHM_AES_256; + else + throw new EncryptedDocumentException("Unsupported block size"); + } else { + throw new EncryptedDocumentException("Unsupported cipher"); + } + + String chain = keyData.getNamedItem("cipherChaining").getNodeValue(); + if ("ChainingModeCBC".equals(chain)) + cipherMode = EncryptionHeader.MODE_CBC; + else if ("ChainingModeCFB".equals(chain)) + cipherMode = EncryptionHeader.MODE_CFB; + else + throw new EncryptedDocumentException("Unsupported chaining mode"); + + verifierHashSize = Integer.parseInt(keyData.getNamedItem("hashSize") + .getNodeValue()); + } public EncryptionVerifier(DocumentInputStream is, int encryptedLength) { int saltSize = is.readInt(); @@ -34,13 +121,20 @@ public class EncryptionVerifier { throw new RuntimeException("Salt size != 16 !?"); } + salt = new byte[16]; is.readFully(salt); + verifier = new byte[16]; is.readFully(verifier); verifierHashSize = is.readInt(); verifierHash = new byte[encryptedLength]; is.readFully(verifierHash); + + spinCount = 50000; + algorithm = EncryptionHeader.ALGORITHM_AES_128; + cipherMode = EncryptionHeader.MODE_ECB; + encryptedKey = null; } public byte[] getSalt() { @@ -54,4 +148,20 @@ public class EncryptionVerifier { public byte[] getVerifierHash() { return verifierHash; } + + public int getSpinCount() { + return spinCount; + } + + public int getCipherMode() { + return cipherMode; + } + + public int getAlgorithm() { + return algorithm; + } + + public byte[] getEncryptedKey() { + return encryptedKey; + } } diff --git a/src/testcases/org/apache/poi/poifs/crypt/AllPOIFSCryptoTests.java b/src/testcases/org/apache/poi/poifs/crypt/AllPOIFSCryptoTests.java new file mode 100644 index 0000000000..d7aef1039f --- /dev/null +++ b/src/testcases/org/apache/poi/poifs/crypt/AllPOIFSCryptoTests.java @@ -0,0 +1,37 @@ +/* ==================================================================== + Licensed to the Apache Software Foundation (ASF) under one or more + contributor license agreements. See the NOTICE file distributed with + this work for additional information regarding copyright ownership. + The ASF licenses this file to You under the Apache License, Version 2.0 + (the "License"); you may not use this file except in compliance with + the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +==================================================================== */ + +package org.apache.poi.poifs.crypt; + +import junit.framework.Test; +import junit.framework.TestSuite; + + +/** + * Tests for org.apache.poi.poifs.crypt + * + * @author Gary King + */ +public final class AllPOIFSCryptoTests { + + public static Test suite() { + TestSuite result = new TestSuite(AllPOIFSCryptoTests.class.getName()); + result.addTestSuite(TestDecryptor.class); + result.addTestSuite(TestEncryptionInfo.class); + return result; + } +}
\ No newline at end of file diff --git a/src/testcases/org/apache/poi/poifs/crypt/DecryptorTest.java b/src/testcases/org/apache/poi/poifs/crypt/TestDecryptor.java index bb317d77c8..27385719c8 100644 --- a/src/testcases/org/apache/poi/poifs/crypt/DecryptorTest.java +++ b/src/testcases/org/apache/poi/poifs/crypt/TestDecryptor.java @@ -27,14 +27,15 @@ import java.util.zip.ZipInputStream; /** * @author Maxim Valyanskiy + * @author Gary King */ -public class DecryptorTest extends TestCase { +public class TestDecryptor extends TestCase { public void testPasswordVerification() throws IOException, GeneralSecurityException { POIFSFileSystem fs = new POIFSFileSystem(POIDataSamples.getPOIFSInstance().openResourceAsStream("protect.xlsx")); EncryptionInfo info = new EncryptionInfo(fs); - Decryptor d = new Decryptor(info); + Decryptor d = Decryptor.getInstance(info); assertTrue(d.verifyPassword(Decryptor.DEFAULT_PASSWORD)); } @@ -44,13 +45,27 @@ public class DecryptorTest extends TestCase { EncryptionInfo info = new EncryptionInfo(fs); - Decryptor d = new Decryptor(info); + Decryptor d = Decryptor.getInstance(info); d.verifyPassword(Decryptor.DEFAULT_PASSWORD); zipOk(fs, d); } + public void testAgile() throws IOException, GeneralSecurityException { + POIFSFileSystem fs = new POIFSFileSystem(POIDataSamples.getPOIFSInstance().openResourceAsStream("protected_agile.docx")); + + EncryptionInfo info = new EncryptionInfo(fs); + + assertTrue(info.getVersionMajor() == 4 && info.getVersionMinor() == 4); + + Decryptor d = Decryptor.getInstance(info); + + assertTrue(d.verifyPassword(Decryptor.DEFAULT_PASSWORD)); + + zipOk(fs, d); + } + private void zipOk(POIFSFileSystem fs, Decryptor d) throws IOException, GeneralSecurityException { ZipInputStream zin = new ZipInputStream(d.getDataStream(fs)); diff --git a/src/testcases/org/apache/poi/poifs/crypt/EncryptionInfoTest.java b/src/testcases/org/apache/poi/poifs/crypt/TestEncryptionInfo.java index eb84727e33..62607e7e8a 100644 --- a/src/testcases/org/apache/poi/poifs/crypt/EncryptionInfoTest.java +++ b/src/testcases/org/apache/poi/poifs/crypt/TestEncryptionInfo.java @@ -25,7 +25,7 @@ import java.io.IOException; /** * @author Maxim Valyanskiy */ -public class EncryptionInfoTest extends TestCase { +public class TestEncryptionInfo extends TestCase { public void testEncryptionInfo() throws IOException { POIFSFileSystem fs = new POIFSFileSystem(POIDataSamples.getPOIFSInstance().openResourceAsStream("protect.xlsx")); diff --git a/test-data/poifs/protected_agile.docx b/test-data/poifs/protected_agile.docx Binary files differnew file mode 100644 index 0000000000..a7de3ebe43 --- /dev/null +++ b/test-data/poifs/protected_agile.docx |