From: Maxim Valyanskiy Date: Tue, 10 May 2011 10:38:17 +0000 (+0000) Subject: bug#51165: Add support for OOXML Agile Encryption X-Git-Tag: REL_3_8_BETA3~38 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=138bd6f94c47adb06d109b5b1a342a0b37547c0c;p=poi.git 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 --- diff --git a/build.xml b/build.xml index 9112de255f..5f4c5aada4 100644 --- a/build.xml +++ b/build.xml @@ -122,6 +122,9 @@ under the License. + + @@ -166,6 +169,7 @@ under the License. + @@ -295,6 +299,7 @@ under the License. + @@ -311,6 +316,10 @@ under the License. + + + + 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 @@ + + + + + + +
+ Apache POI - Encryption support + + + +
+ + +
Overview +

Apache POI contains support for reading few variants of encrypted office files:

+
    +
  • XLS - RC4 Encryption
  • +
  • XML-based formats (XLSX, DOCX and etc) - AES Encryption
  • +
+ +

Some "write-protected" files are encrypted with build-in password, POI can read that files too.

+
+ +
XLS +

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. +

+
+ +
XML-based formats +

XML-based formats are stored in OLE-package stream "EncryptedPackage". Use org.apache.poi.poifs.crypt.Decryptor + to decode file:

+ + +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); +} + + +

If you want to read file encrypted with build-in password, use Decryptor.DEFAULT_PASSWORD.

+
+ + +
+ + Copyright (c) @year@ The Apache Software Foundation. All rights reserved. + +
+
+ + + + 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 source.length) { - for(int i=source.length; i source.length) { + for(int i=source.length; i0) { - zin.skip(zin.available()); - } - } - } -} diff --git a/src/testcases/org/apache/poi/poifs/crypt/EncryptionInfoTest.java b/src/testcases/org/apache/poi/poifs/crypt/EncryptionInfoTest.java deleted file mode 100644 index eb84727e33..0000000000 --- a/src/testcases/org/apache/poi/poifs/crypt/EncryptionInfoTest.java +++ /dev/null @@ -1,45 +0,0 @@ -/* ==================================================================== - 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.TestCase; -import org.apache.poi.POIDataSamples; -import org.apache.poi.poifs.filesystem.POIFSFileSystem; - -import java.io.IOException; - -/** - * @author Maxim Valyanskiy - */ -public class EncryptionInfoTest extends TestCase { - public void testEncryptionInfo() throws IOException { - POIFSFileSystem fs = new POIFSFileSystem(POIDataSamples.getPOIFSInstance().openResourceAsStream("protect.xlsx")); - - EncryptionInfo info = new EncryptionInfo(fs); - - assertEquals(3, info.getVersionMajor()); - assertEquals(2, info.getVersionMinor()); - - assertEquals(EncryptionHeader.ALGORITHM_AES_128, info.getHeader().getAlgorithm()); - assertEquals(EncryptionHeader.HASH_SHA1, info.getHeader().getHashAlgorithm()); - assertEquals(128, info.getHeader().getKeySize()); - assertEquals(EncryptionHeader.PROVIDER_AES, info.getHeader().getProviderType()); - assertEquals("Microsoft Enhanced RSA and AES Cryptographic Provider", info.getHeader().getCspName()); - - assertEquals(32, info.getVerifier().getVerifierHash().length); - } -} diff --git a/src/testcases/org/apache/poi/poifs/crypt/TestDecryptor.java b/src/testcases/org/apache/poi/poifs/crypt/TestDecryptor.java new file mode 100644 index 0000000000..27385719c8 --- /dev/null +++ b/src/testcases/org/apache/poi/poifs/crypt/TestDecryptor.java @@ -0,0 +1,83 @@ +/* ==================================================================== + 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.TestCase; +import org.apache.poi.POIDataSamples; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** + * @author Maxim Valyanskiy + * @author Gary King + */ +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 = Decryptor.getInstance(info); + + assertTrue(d.verifyPassword(Decryptor.DEFAULT_PASSWORD)); + } + + public void testDecrypt() throws IOException, GeneralSecurityException { + POIFSFileSystem fs = new POIFSFileSystem(POIDataSamples.getPOIFSInstance().openResourceAsStream("protect.xlsx")); + + EncryptionInfo info = new EncryptionInfo(fs); + + 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)); + + while (true) { + ZipEntry entry = zin.getNextEntry(); + if (entry==null) { + break; + } + + while (zin.available()>0) { + zin.skip(zin.available()); + } + } + } +} diff --git a/src/testcases/org/apache/poi/poifs/crypt/TestEncryptionInfo.java b/src/testcases/org/apache/poi/poifs/crypt/TestEncryptionInfo.java new file mode 100644 index 0000000000..62607e7e8a --- /dev/null +++ b/src/testcases/org/apache/poi/poifs/crypt/TestEncryptionInfo.java @@ -0,0 +1,45 @@ +/* ==================================================================== + 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.TestCase; +import org.apache.poi.POIDataSamples; +import org.apache.poi.poifs.filesystem.POIFSFileSystem; + +import java.io.IOException; + +/** + * @author Maxim Valyanskiy + */ +public class TestEncryptionInfo extends TestCase { + public void testEncryptionInfo() throws IOException { + POIFSFileSystem fs = new POIFSFileSystem(POIDataSamples.getPOIFSInstance().openResourceAsStream("protect.xlsx")); + + EncryptionInfo info = new EncryptionInfo(fs); + + assertEquals(3, info.getVersionMajor()); + assertEquals(2, info.getVersionMinor()); + + assertEquals(EncryptionHeader.ALGORITHM_AES_128, info.getHeader().getAlgorithm()); + assertEquals(EncryptionHeader.HASH_SHA1, info.getHeader().getHashAlgorithm()); + assertEquals(128, info.getHeader().getKeySize()); + assertEquals(EncryptionHeader.PROVIDER_AES, info.getHeader().getProviderType()); + assertEquals("Microsoft Enhanced RSA and AES Cryptographic Provider", info.getHeader().getCspName()); + + assertEquals(32, info.getVerifier().getVerifierHash().length); + } +} diff --git a/test-data/poifs/protected_agile.docx b/test-data/poifs/protected_agile.docx new file mode 100644 index 0000000000..a7de3ebe43 Binary files /dev/null and b/test-data/poifs/protected_agile.docx differ