Skip to content

Commit

Permalink
Merge pull request #86 from stephane-rouleau/encryption
Browse files Browse the repository at this point in the history
Add encryption support for OOXML
  • Loading branch information
nolze authored Jan 18, 2024
2 parents 84475e7 + 5cd2ee1 commit 7412cc4
Show file tree
Hide file tree
Showing 8 changed files with 1,034 additions and 33 deletions.
36 changes: 35 additions & 1 deletion LICENSE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,38 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

----------------------------------------------------------------------

This software contains derivative works from https://github.com/herumi/msoffice
which is licensed under the BSD 3-Clause License.

https://github.com/herumi/msoffice/blob/c3cdb1ea0a5285a2a1718fee2dc893fd884bdad0/COPYRIGHT

Copyright (c) 2007-2015 Cybozu Labs, Inc.
All rights reserved.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:

Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
Neither the name of the Cybozu Labs, Inc. nor the names of its contributors may
be used to endorse or promote products derived from this software without
specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
THE POSSIBILITY OF SUCH DAMAGE.
60 changes: 57 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ pip install msoffcrypto-tool

### As CLI tool (with password)

#### Decryption

Specify the password with `-p` flag:

```
msoffcrypto-tool encrypted.docx decrypted.docx -p Passw0rd
```
Expand All @@ -41,16 +45,31 @@ $ msoffcrypto-tool encrypted.docx decrypted.docx -p
Password:
```

Test if the file is encrypted or not (exit code 0 or 1 is returned):
To check if the file is encrypted or not, use `-t` flag:

```
msoffcrypto-tool document.doc --test -v
```

It returns `1` if the file is encrypted, `0` if not.

#### Encryption (OOXML only, experimental)

> [!IMPORTANT]
> Encryption feature is experimental. Please use it at your own risk.
To password-protect a document, use `-e` flag along with `-p` flag:

```
msoffcrypto-tool -e -p Passw0rd plain.docx encrypted.docx
```

### As library

Password and more key types are supported with library functions.

#### Decryption

Basic usage:

```python
Expand All @@ -67,7 +86,7 @@ with open("decrypted.docx", "wb") as f:
encrypted.close()
```

Basic usage (in-memory):
In-memory:

```python
import msoffcrypto
Expand Down Expand Up @@ -104,6 +123,40 @@ file.load_key(secret_key=binascii.unhexlify("AE8C36E68B4BB9EA46E5544A5FDB6693875
file.decrypt(open("decrypted.docx", "wb"), verify_integrity=True)
```

#### Encryption (OOXML only, experimental)

> [!IMPORTANT]
> Encryption feature is experimental. Please use it at your own risk.
Basic usage:

```python
from msoffcrypto.format.ooxml import OOXMLFile

plain = open("plain.docx", "rb")
file = OOXMLFile(plain)

with open("encrypted.docx", "wb") as f:
file.encrypt("Passw0rd", f)

plain.close()
```

In-memory:

```python
from msoffcrypto.format.ooxml import OOXMLFile
import io

encrypted = io.BytesIO()

with open("plain.xlsx", "rb") as f:
file = OOXMLFile(f)
file.encrypt("Passw0rd", encrypted)

# Do stuff with encrypted buffer; it contains an OLE container with an encrypted stream
```

## Supported encryption methods

### MS-OFFCRYPTO specs
Expand Down Expand Up @@ -155,7 +208,8 @@ poetry run coverage run -m pytest -v
* [x] Improve error types (v4.12.0)
* [ ] Redesign APIs (v6.0.0)
* [ ] Introduce something like `ctypes.Structure`
* [ ] Support encryption
* [x] Support OOXML encryption
* [ ] Support other encryption
* [ ] Isolate parser

## Resources
Expand Down
24 changes: 16 additions & 8 deletions msoffcrypto/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import olefile

from msoffcrypto import OfficeFile, exceptions
from msoffcrypto.format.ooxml import OOXMLFile

logger = logging.getLogger(__name__)
logger.addHandler(logging.NullHandler())
Expand Down Expand Up @@ -53,6 +54,7 @@ def is_encrypted(file):
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("-p", "--password", nargs="?", const="", dest="password", help="password text")
group.add_argument("-t", "--test", dest="test_encrypted", action="store_true", help="test if the file is encrypted")
parser.add_argument("-e", dest="encrypt", action="store_true", help="encryption mode (default is false)")
parser.add_argument("-v", dest="verbose", action="store_true", help="print verbose information")
parser.add_argument("infile", nargs="?", type=argparse.FileType("rb"), help="input file")
parser.add_argument("outfile", nargs="?", type=argparse.FileType("wb"), help="output file (if blank, stdout is used)")
Expand All @@ -75,16 +77,10 @@ def main():
logger.debug("{}: encrypted".format(args.infile.name))
return

if not olefile.isOleFile(args.infile):
raise exceptions.FileFormatError("Not OLE file")

file = OfficeFile(args.infile)

if args.password:
file.load_key(password=args.password)
password = args.password
else:
password = getpass.getpass()
file.load_key(password=password)

if args.outfile is None:
ifWIN32SetBinary(sys.stdout)
Expand All @@ -93,7 +89,19 @@ def main():
else:
args.outfile = sys.stdout

file.decrypt(args.outfile)
if args.encrypt:
# OOXML is the only format we support for encryption
file = OOXMLFile(args.infile)

file.encrypt(password, args.outfile)
else:
if not olefile.isOleFile(args.infile):
raise exceptions.FileFormatError("Not OLE file")

file = OfficeFile(args.infile)
file.load_key(password=password)

file.decrypt(args.outfile)


if __name__ == "__main__":
Expand Down
22 changes: 14 additions & 8 deletions msoffcrypto/exceptions/__init__.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
class FileFormatError(Exception):
"""Raised when the format of given file is unsupported or unrecognized.
"""
"""Raised when the format of given file is unsupported or unrecognized."""

pass


class ParseError(Exception):
"""Raised when the file cannot be parsed correctly.
"""
"""Raised when the file cannot be parsed correctly."""

pass


class DecryptionError(Exception):
"""Raised when the file cannot be decrypted.
"""
"""Raised when the file cannot be decrypted."""

pass


class EncryptionError(Exception):
"""Raised when the file cannot be encrypted."""

pass


class InvalidKeyError(DecryptionError):
"""Raised when the given password or key is incorrect or cannot be verified.
"""
"""Raised when the given password or key is incorrect or cannot be verified."""

pass
20 changes: 20 additions & 0 deletions msoffcrypto/format/ooxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,26 @@ def decrypt(self, ofile, verify_integrity=False):
if not zipfile.is_zipfile(io.BytesIO(obuf)):
raise exceptions.InvalidKeyError("The file could not be decrypted with this password")

def encrypt(self, password, ofile):
"""
>>> from msoffcrypto.format.ooxml import OOXMLFile
>>> from io import BytesIO; ofile = BytesIO()
>>> with open("tests/outputs/example.docx", "rb") as f:
... officefile = OOXMLFile(f)
... officefile.encrypt("1234", ofile)
"""
if self.is_encrypted():
raise exceptions.EncryptionError("File is already encrypted")

self.file.seek(0)

buf = ECMA376Agile.encrypt(password, self.file)

if not olefile.isOleFile(buf):
raise exceptions.EncryptionError("Unable to encrypt this file")

ofile.write(buf)

def is_encrypted(self):
"""
>>> with open("tests/inputs/example_password.docx", "rb") as f:
Expand Down
Loading

0 comments on commit 7412cc4

Please sign in to comment.