Source code for src.ch06.p1_invisible_ink
"""Use stenography to hide messages in a word processor document.
Use :py:obj:`docx.Document` to hide encrypted messages in a word processor
document by embedding the encrypted message in a fake message's whitespace,
then changing the encrypted message's font color to white.
Note:
Using LibreOffice version 6.0.7.3
Warning:
There are many ways this method of stenography can fail. Please don't use
for actual covert operations (covered in MIT License).
"""
from pathlib import Path, PurePath
import docx
from docx.shared import RGBColor, Pt
[docs]def get_text(file_path: str, skip_blank: bool = True) -> list:
"""Get text from a docx file.
Loads paragraphs from the given docx file into a list. Optionally skips
blank lines.
Args:
file_path (str): Absolute path to a .docx file to load.
skip_blank (bool): Whether or not to skip blank lines. Defaults
to :py:obj:`True`.
Returns:
Each paragraph in the docx file in a list of strings.
Note:
Does not copy formatting from docx file - only text.
"""
paragraphs = []
doc = docx.Document(file_path)
for paragraph in doc.paragraphs:
if all([skip_blank, not paragraph.text]):
continue
paragraphs.append(paragraph.text)
return paragraphs
[docs]def check_blanks(plaintext: list, ciphertext: list) -> int:
"""Check if the ciphertext can fit in plaintext.
Compare the number of blank lines in **plaintext** to the number of lines
in **ciphertext**. If they aren't a match, returns the number of extra
blank lines needed.
Args:
plaintext (list): Paragraphs of a fake message in a list of strings
(likely from :func:`get_text`).
ciphertext (list): Paragraphs of an encrypted message in a list of
strings (likely from :func:`get_text`).
Returns:
Integer representing the number of needed blank lines to fit
**ciphertext** in **plaintext**. ``0`` would mean that **ciphertext**
can fit in **plaintext**.
"""
blanks_needed = len(ciphertext) - plaintext.count('')
if blanks_needed <= 0:
return 0
return blanks_needed
[docs]def write_invisible(plaintext: list, ciphertext: list,
template_path: str = None,
filename: str = 'output.docx') -> None:
"""Embed ciphertext in plaintext's whitespace.
Open a template file, **template_path**, with the needed fonts, styles,
and margins. Write each paragraph in **plaintext** to the template file
and add each paragraph in **ciphertext** to **plaintext**'s blank space.
Save the new file as **filename**.
Args:
plaintext (list): Paragraphs of a fake message in a list of strings
(likely from :func:`get_text`).
ciphertext (list): Paragraphs of an encrypted message in a list of
strings (likely from :func:`get_text`).
template_path (str): Absolute path to .docx file with predefined
fonts, styles, and margins. Defaults to :py:obj:`None`. If not
provided, defaults will be created.
filename (str): File name to use for output file. Defaults to
``output.docx``.
Returns:
:py:obj:`None`. **plaintext** is written to the file at
**template_path** with **ciphertext** embedded in the blank space.
Raises:
ValueError: If the number of blank lines in **plaintext** aren't
enough to embed **ciphertext** based on output of
:func:`check_blanks`.
Note:
As of python-docx v0.8.10, creating custom styles isn't well
supported. More info `here`_.
As a result, if a template isn't provided, the default template is
used.
.. _here:
https://python-docx.readthedocs.io/en/latest/user/styles-understanding.html
"""
blanks_needed = check_blanks(plaintext, ciphertext)
if blanks_needed > 0:
raise ValueError(f'{blanks_needed} more blanks are needed in the '
f'plaintext (fake) message.')
if template_path is None:
# Use default template.
doc = docx.Document()
else:
doc = docx.Document(template_path)
index = 0
for line in plaintext:
if all([line == '', index < len(ciphertext)]):
paragraph = doc.add_paragraph(ciphertext[index])
paragraph_index = len(doc.paragraphs) - 1
# Set real message color to white.
run = doc.paragraphs[paragraph_index].runs[0]
font = run.font
# Make red for testing.
font.color.rgb = RGBColor(255, 255, 255)
index += 1
else:
paragraph = doc.add_paragraph(line)
# Set line spacing between paragraphs.
paragraph_format = paragraph.paragraph_format
paragraph_format.space_before = Pt(0)
paragraph_format.space_after = Pt(0)
doc.save(filename)
[docs]def main(fakefile: str = None, cipherfile: str = None,
savepath: str = None) -> None:
"""Demonstrate the invisible ink writer.
Demonstrate :func:`write_invisible`, but for testing,
it is a basic wrapper function for :func:`write_invisible`.
Embed **cipherfile** in **fakefile**'s whitespace.
Args:
fakefile (str): Path to .docx file with fake message.
Defaults to ``./p1files/fake.docx``.
cipherfile (str): Path to .docx file with real message.
Defaults to ``./p1files/real.docx``.
savepath (str): Path to .docx file for output.
Defaults to ``./p1files/LetterToUSDA.docx``.
Returns:
:py:obj:`None`. The contents of **cipherfile**'s text is embedded
in **fakefile**'s whitespace and saved to **savepath**.
"""
print('I can embed a hidden message in a .docx file\'s white space '
'by making the font\ncolor white. It isn\'t as bulletproof '
'as it sounds.\n')
current_dir = Path('./p1files').resolve()
if fakefile is None or cipherfile is None:
fakefile = PurePath(current_dir).joinpath('fake.docx')
cipherfile = PurePath(current_dir).joinpath('real.docx')
if savepath is None:
savepath = PurePath(current_dir).joinpath('LetterToUSDA.docx')
faketext = get_text(fakefile, False)
ciphertext = get_text(cipherfile)
write_invisible(faketext, ciphertext, None, savepath)
print('Done.\n')
print('To read the real message, select the entire document and\n'
'highlight it a dark gray.')
if __name__ == '__main__':
main()