diff --git a/.gitignore b/.gitignore index f8433db..b69ab13 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ build/ # Chapter 6 output.docx LetterToUSDA.docx +DearInternet.docx diff --git a/docs/source/src.ch06.rst b/docs/source/src.ch06.rst index a6c4e08..744e27b 100644 --- a/docs/source/src.ch06.rst +++ b/docs/source/src.ch06.rst @@ -4,6 +4,14 @@ src.ch06 package Submodules ---------- +src.ch06.c1\_invisible\_ink\_mono module +---------------------------------------- + +.. automodule:: src.ch06.c1_invisible_ink_mono + :members: + :undoc-members: + :show-inheritance: + src.ch06.p1\_invisible\_ink module ---------------------------------- diff --git a/src/ch06/c1_invisible_ink_mono.py b/src/ch06/c1_invisible_ink_mono.py new file mode 100644 index 0000000..368945c --- /dev/null +++ b/src/ch06/c1_invisible_ink_mono.py @@ -0,0 +1,179 @@ +"""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 +from platform import system + +import docx +from docx.shared import RGBColor + +from src.ch06.p1_invisible_ink import get_text + + +def check_fit(plaintext: list, ciphertext: list) -> int: + """Check if ciphertext can fit in plaintext's whitespace. + + Sum number of blanks in **plaintext** and compare to number of characters + in **ciphertext** to see if it can fit. + + Args: + plaintext (list): Paragraphs of a fake message in a list of strings + (likely from :func:`~src.ch06.p1_invisible_ink.get_text`). + ciphertext (list): Paragraphs of an encrypted message in a list of + strings (likely from :func:`~src.ch06.p1_invisible_ink.get_text`). + + Returns: + Integer representing the number of needed blanks to fit + **ciphertext** in **plaintext**. ``0`` would mean that **ciphertext** + can fit in **plaintext**. + + Note: + To separate words, the blanks in **ciphertext** count toward the + needed length of **plaintext**. By contrast, blank lines in + **plaintext** do not count. + + """ + blanks = sum(line.count(' ') for line in plaintext if line != '') + letters = sum(len(line) for line in ciphertext if line != '') + if blanks >= letters: + return 0 + return letters - blanks + + +def write_invisible(plaintext: list, ciphertext: list, + template_path: str = None, + filename: str = 'output.docx') -> None: + """Embed ciphertext in plaintext's letter whitespace. + + Open a template file, **template_path**, with the needed fonts, styles, + and margins. Write each line in **plaintext** to the template file + and add each line in **ciphertext** to **plaintext**'s space between + letters by using a monospace font. + Save the new file as **filename**. + + Args: + plaintext (list): Lines of a fake message in a list of strings + (likely from :func:`~src.ch06.p1_invisible_ink.get_text`). + ciphertext (list): Lines of an encrypted message in a list of + strings (likely from :func:`~src.ch06.p1_invisible_ink.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 spaces in **plaintext** aren't + enough to embed **ciphertext** based on output of + :func:`check_fit`. + + 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 + modified to use a font named ``Courier New`` on Windows and + ``Liberation Mono`` on other operating systems in the ``Normal`` + style. + + .. _here: + https://python-docx.readthedocs.io/en/latest/user/styles-understanding.html + + """ + blanks_needed = check_fit(plaintext, ciphertext) + if blanks_needed > 0: + raise ValueError(f'Need {blanks_needed} more spaces in the plaintext ' + f'(fake) message.') + if template_path is None: + # Modify default template. + doc = docx.Document() + style = doc.styles['Normal'] + font = style.font + if system().lower().startswith('windows'): + font.name = 'Courier New' + else: + font.name = 'Liberation Mono' + else: + doc = docx.Document(template_path) + + line_index, letter_index = 0, 0 + for line in plaintext: + # Add new paragraph to template. + paragraph = doc.add_paragraph() + paragraph_index = len(doc.paragraphs) - 1 + for letter in line: + # Add each letter to paragraph. + if all([letter == ' ', + letter_index < len(ciphertext[line_index])]): + # Add real message to space and set color to white. + paragraph.add_run(ciphertext[line_index][letter_index]) + run = doc.paragraphs[paragraph_index].runs[-1] + font = run.font + # Make red for testing. + font.color.rgb = RGBColor(255, 255, 255) + letter_index += 1 + else: + paragraph.add_run(letter) + if all([letter_index >= len(ciphertext[line_index]), + line_index < len(ciphertext) - 1]): + # Go to next line in ciphertext if end reached. + line_index += 1 + letter_index = 0 + doc.save(filename) + + +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 ``./c1files/fake.docx``. + cipherfile (str): Path to .docx file with real message. + Defaults to ``./c1files/real.docx``. + savepath (str): Path to .docx file for output. + Defaults to ``./c1files/DearInternet.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 ' + 'between letters by making the font\ncolor white. It\'s far less ' + 'bulletproof than it sounds.\n') + current_dir = Path('./c1files').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('DearInternet.docx') + faketext = get_text(fakefile, False) + ciphertext = get_text(cipherfile) + write_invisible(faketext, ciphertext, None, savepath) + print('Fin.\n') + print('To read the hidden message, select the entire document and\n' + 'highlight it a darkish gray.') + + +if __name__ == '__main__': + main() diff --git a/src/ch06/c1files/fake.docx b/src/ch06/c1files/fake.docx new file mode 100644 index 0000000..7a85100 Binary files /dev/null and b/src/ch06/c1files/fake.docx differ diff --git a/src/ch06/c1files/real.docx b/src/ch06/c1files/real.docx new file mode 100644 index 0000000..4a12e3a Binary files /dev/null and b/src/ch06/c1files/real.docx differ diff --git a/tests/data/__init__.py b/tests/data/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/ch06/__init__.py b/tests/data/ch06/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/data/ch06/cipher_mono.docx b/tests/data/ch06/cipher_mono.docx new file mode 100644 index 0000000..d6aac33 Binary files /dev/null and b/tests/data/ch06/cipher_mono.docx differ diff --git a/tests/data/ch06/constants.py b/tests/data/ch06/constants.py new file mode 100644 index 0000000..1fd68ce --- /dev/null +++ b/tests/data/ch06/constants.py @@ -0,0 +1,111 @@ +"""Test Chapter 6 Constants. + +Constants for test_chapter06.py. + +Attributes: + GET_TEST (list): List of strings with expected output of + tests.test_chapter06.TestInvisibleInk.test_get_text test values. + WRITE_DEFAULT_MONO (list): List of strings with expected output of + src.ch06.c1_invisible_ink_mono.write_invisible default values. + WRITE_TEST_MONO (list): List of strings with expected output of + tests.test_chapter06.TestInvisibleInkMono.test_write_invisible test + values. + MAIN_TEST_MONO (list): List of strings with expected output of + tests.test_chapter06.TestInvisibleInkMono.test_main test values. + MAIN_DEFAULT_MONO (list): List of strings with expected output of + src.ch06.c1_invisible_ink_mono.main default values. + +""" +GET_TEST = [ + 'This is a test document.', + 'This is a paragraph with two runs. However, it’s not because it has two ' + 'lines.', + 'There is intentionally a lot of blank spaces to check if the code can count ' + 'them correctly.', + 'So, don’t send me e-mails saying that the formatting in my test files is ' + 'incorrect.', + 'Word.'] + +WRITE_DEFAULT_MONO = [ + 'ThisTishaitestsdocument withiaslot ofsfiller,' + 'uorpunnecessarypwordiness.', + 'Please,otrysnotetodwrite liketthisobecause itbiseas annoyingaas ' + 'itsishunnecessary.', + 'Unless,oofrcourse,tyou,are ' + 'writinguantypeeofncharactercthatrisywordypandtusesewordsd' + 'unnecessarily.', + 'In thatmrare,euncommonsinstance,sitaisgperfectlyepermissible.' + 'to be wordy.'] + +WRITE_TEST_MONO = [ + 'ThisTishaitestsdocument withiaslot ofsfiller,' + 'uorpunnecessarypwordiness.', + 'Please,otrysnotetodwrite liketthisobecause itbiseas annoyingaas ' + 'itsishunnecessary.', + 'Unless,oofrcourse,tyou,are ' + 'writinguantypeeofncharactercthatrisywordypandtusesewordsd' + 'unnecessarily.', + 'In thatmrare,euncommonsinstance,sitaisgperfectlyepermissible.' + 'toHbeiwordy.'] + +MAIN_TEST_MONO = [ + 'ThisTishaitestsdocument withiaslot ofsfiller,uorpunnecessarypwordiness.', + 'Please,otrysnotetodwrite liketthisobecause itbiseas annoyingaas ' + 'itsishunnecessary.', + 'Unless,oofrcourse,tyou,are ' + 'writinguantypeeofncharactercthatrisywordypandtusesewordsdunnecessarily.', + 'In thatmrare,euncommonsinstance,sitaisgperfectlyepermissible.to be wordy.'] + +MAIN_DEFAULT_MONO = [ + 'DearYInternet,', + 'Schoolotaughtumerthat yourstartedeoutaaslARPANET: ' + 'twoncomputerasystemsm(specifically,eUCLA’s NetworkiMeasurementsCenter ' + 'andaSRI’s ' + 'NLSpsystem)oconnectedrtogethertwaymbackaonnOctobert29,e1969.aIurecently ' + 'learnedothefTCP/IP ' + 'standardiwasn’tnadoptedtuntileMarchr1982candothatnaccessntoethecTCP/IPtnetworkewasn’tdexpanded ' + 'untiln1986ewhentthewNSFoprovidedraccesskfor.researchers ' + 'toYconnectotousupercomputer sitesainrtheeUnited Statesaat ' + 'thenblazingespeedtofw56okbit/s.\n' + '\n' + 'Surprisingly,rcommercialkISPs didn’tocomefaround ' + 'untilntheelatet1980swandoearlyr1990s,katswhich ' + 'pointtthehARPANETawastdecommissioned.', + 'I remembercinothenearlys1990sithatsusingtascomputer tooinstantfmessage ' + 'someoneponrtheiothervsideaofttheeworld,was ' + 'apbigudealbbecauselthatiwascas,close ' + 'toarealctimeaasdpossibleewithoutmrackingiupca,major ' + 'longbdistanceubillsonithenlandline.eAlthough,sIsam,curious ' + 'whatathenlatencydwas backgthen…', + 'Theseodays,vyouearerprovidingnusmaewayntotwirelessly ' + 'usenpocketecomputersttowcheckoinrtokoursfavorite coffeeoshop,fstream ' + 'musiclandovideocwhilealaughinglat danktmemesoon ' + 'socialgmedia,landopaybouratablwith astapcusingoourpvirtualewallet.', + 'You,connect solmanyicomputersntogetherkthateadnew IPbprotocolyhad toaget ' + 'standardizedbtorsupportothemaalldas theyacontinuertorgrowainynumber. ' + 'Everythingofromfa ' + 'whiteepaperlonecuttingcedgetresearchrtooannoldiblogcabout,cats ' + 'havewtheiriownrIPeaddress.lNotetosmentionsall,the peopleaconnectingntodread ' + 'moreoaboutpthem.', + 'Inttheifuture,cweawillllikely ' + 'expectnmoreefromtyouwasoanrincreasingknumberiofnInternetgusers ' + 'expecttwirelessenetworksctohhandlenasomuchltrafficoasgbroadbandinetworkseandsas.laptops ' + 'andBdesktopsyare eithertrelegatedhtoegaming orwofficeawork.\n' + '\n' + 'Perhapsywe,will relyIon youaevenmmore ' + 'astcompaniesrforgouconventionalldesktopsyand, ' + 'instead,soptuforrthinpclientsrmadeiofsaegraphicsdcard andbEthernetycard ' + 'withhmultipleoscreenswthat connectmtouaccloudhserver ' + 'instancetthateinstantiatesxonlyton userflogin,ithentloadssuser ' + 'settingsiandnworkspaces fromtahcentralizedaserver.tThen, ' + 'whenftheauserkisedone, saveslinstanceedatatbackttoethercentralized.server ' + 'andMthenadeactivatesytobsaveeresources.', + 'Some systemsIalready implementsyouhusingolightuoutlindthe ' + 'openhairabecausevdataeis transmittedtfasterothatnway.eIndaddition ' + 'toithethuge bundlesdofocablewthatnstretch acrossathe ' + 'oceanbflooritotconnect?both sidesNofathehworld,together.', + 'I lookeforwardxtocwhateyouswillsbecomeiinvtheefuturen–eevensifsit ' + 'isidangerous,shigh-powered lasergbeamsoandoevenddanker.memes.', + 'Sincerely,', + 'Jose', + 'Works consulted: https://en.wikipedia.org/wiki/Internet'] diff --git a/tests/data/ch06/fake_mono.docx b/tests/data/ch06/fake_mono.docx new file mode 100644 index 0000000..3abc79e Binary files /dev/null and b/tests/data/ch06/fake_mono.docx differ diff --git a/tests/data/ch06/main/invisible_ink_mono.txt b/tests/data/ch06/main/invisible_ink_mono.txt new file mode 100644 index 0000000..dccfa6d --- /dev/null +++ b/tests/data/ch06/main/invisible_ink_mono.txt @@ -0,0 +1,7 @@ +I can embed a hidden message in a .docx file's white space between letters by making the font +color white. It's far less bulletproof than it sounds. + +Fin. + +To read the hidden message, select the entire document and +highlight it a darkish gray. diff --git a/tests/data/ch06/template_mono.docx b/tests/data/ch06/template_mono.docx new file mode 100644 index 0000000..dfc0248 Binary files /dev/null and b/tests/data/ch06/template_mono.docx differ diff --git a/tests/test_chapter06.py b/tests/test_chapter06.py index 6f7f43d..347dacb 100644 --- a/tests/test_chapter06.py +++ b/tests/test_chapter06.py @@ -2,11 +2,15 @@ import os import unittest.mock from io import StringIO +from platform import system from docx import Document from docx.shared import RGBColor import src.ch06.p1_invisible_ink as invisible_ink +import src.ch06.c1_invisible_ink_mono as invisible_ink_mono + +import tests.data.ch06.constants as constants class TestInvisibleInk(unittest.TestCase): @@ -22,16 +26,7 @@ def test_get_text(self): paragraphs = invisible_ink.get_text(testfile) self.assertEqual(paragraphs.count(''), 0) # Test that it read contents. - answerlist = \ - ['This is a test document.', - 'This is a paragraph with two runs. However, it’s not because it has two ' - 'lines.', - 'There is intentionally a lot of blank spaces to check if the code can count ' - 'them correctly.', - 'So, don’t send me e-mails saying that the formatting in my test files is ' - 'incorrect.', - 'Word.'] - self.assertEqual(answerlist, paragraphs) + self.assertEqual(constants.GET_TEST, paragraphs) def test_check_blanks(self): """Test check_blanks.""" @@ -162,5 +157,192 @@ def test_main(self, mock_stdout, mock_abspath): os.remove(output_file) +class TestInvisibleInkMono(unittest.TestCase): + """Test Invisible Ink Mono.""" + + def test_check_fit(self): + """Test check_fit.""" + fakefile = os.path.normpath('tests/data/ch06/fake_mono.docx') + cipherfile = os.path.normpath('tests/data/ch06/cipher_mono.docx') + # Test that it doesn't need extra blanks. + faketext = invisible_ink.get_text(fakefile, False) + ciphertext = invisible_ink.get_text(cipherfile) + blanks_needed = invisible_ink_mono.check_fit(faketext, ciphertext) + self.assertEqual(blanks_needed, 0) + # Test that it does need extra blanks. + faketext = ['This is too short.'] + blanks_needed = invisible_ink_mono.check_fit(faketext, ciphertext) + self.assertEqual(blanks_needed, 49) + faketext.append('You would have to write a small novel to get it to ' + 'fit.') + blanks_needed = invisible_ink_mono.check_fit(faketext, ciphertext) + self.assertEqual(blanks_needed, 37) + faketext.append('Filling in blanks is not as easy as it seems because ' + 'so few are in every sentence.') + blanks_needed = invisible_ink_mono.check_fit(faketext, ciphertext) + self.assertEqual(blanks_needed, 21) + faketext.append('The use of small words helps, but it is not a good ' + 'way to go about being a super secret spy person.') + blanks_needed = invisible_ink_mono.check_fit(faketext, ciphertext) + self.assertEqual(blanks_needed, 0) + + def test_write_invisible(self): + """Test write_invisible.""" + fakefile = os.path.normpath('tests/data/ch06/fake_mono.docx') + cipherfile = os.path.normpath('tests/data/ch06/cipher_mono.docx') + faketext = invisible_ink.get_text(fakefile, False) + ciphertext = invisible_ink.get_text(cipherfile) + current_dir = os.path.curdir + # Test default template and filename. + invisible_ink_mono.write_invisible(faketext, ciphertext) + output_file = os.path.join(current_dir, 'output.docx') + self.assertTrue(os.path.exists(output_file)) + output_text = invisible_ink.get_text(output_file) + self.assertListEqual(constants.WRITE_DEFAULT_MONO, output_text) + # Check color + paragraph_index, count = 0, 0 + cipher_len = sum(len(line) for line in ciphertext) + doc = Document(output_file) + while count < cipher_len: + for line in faketext: + paragraph = doc.paragraphs[paragraph_index] + if line == '': + # Skip blanks in faketext and output_file. + paragraph_index += 1 + continue + letter_index = 0 + for word in line.split(): + # Check color of each letter after word. + letter_index += len(word) + if letter_index >= len(line): + # Stop checking at the end of the line. + break + run = paragraph.runs[letter_index] + if all([len(run.text) == 1, run.text != ' ']): + self.assertEqual(run.font.color.rgb, RGBColor(255, 255, 255)) + count += 1 + letter_index += 1 + paragraph_index += 1 + os.remove(output_file) + # Test custom template and filename. + template_file = os.path.normpath('tests/data/ch06/template_mono.docx') + output_file = os.path.join(current_dir, 'letter.docx') + invisible_ink_mono.write_invisible(faketext, ciphertext, template_file, 'letter.docx') + self.assertTrue(os.path.exists(output_file)) + output_text = invisible_ink.get_text(output_file) + self.assertListEqual(constants.WRITE_DEFAULT_MONO, output_text) + # Check color + paragraph_index, count = 0, 0 + cipher_len = sum(len(line) for line in ciphertext) + doc = Document(output_file) + while count < cipher_len: + for line in faketext: + paragraph = doc.paragraphs[paragraph_index] + if line == '': + # Skip blanks in faketext and output_file. + paragraph_index += 1 + continue + if paragraph.text == '': + # FIXME: template_file always has a blank paragraph. + paragraph_index += 1 + paragraph = doc.paragraphs[paragraph_index] + letter_index = 0 + for word in line.split(): + # Check color of each letter after word. + letter_index += len(word) + if letter_index >= len(line): + # Stop checking at the end of the line. + break + run = paragraph.runs[letter_index] + if all([len(run.text) == 1, run.text != ' ']): + self.assertEqual(run.font.color.rgb, RGBColor(255, 255, 255)) + count += 1 + letter_index += 1 + paragraph_index += 1 + os.remove(output_file) + # Test font name. + invisible_ink_mono.write_invisible(faketext, ciphertext, None, 'letter.docx') + doc = Document(output_file) + if system().lower().startswith('windows'): + for paragraph in doc.paragraphs: + if paragraph.text == '': + continue + self.assertEqual(paragraph.style.font.name, "Courier New") + else: + for paragraph in doc.paragraphs: + if paragraph.text == '': + continue + self.assertEqual(paragraph.style.font.name, "Liberation Mono") + os.remove(output_file) + # Test multi-line ciphertext. + ciphertext.append('Hi') + invisible_ink_mono.write_invisible(faketext, ciphertext) + output_file = os.path.join(current_dir, 'output.docx') + self.assertTrue(os.path.exists(output_file)) + output_text = invisible_ink.get_text(output_file) + self.assertListEqual(constants.WRITE_TEST_MONO, output_text) + # Check color + paragraph_index, count = 0, 0 + cipher_len = sum(len(line) for line in ciphertext) + doc = Document(output_file) + while count < cipher_len: + for line in faketext: + paragraph = doc.paragraphs[paragraph_index] + if line == '': + # Skip blanks in faketext and output_file. + paragraph_index += 1 + continue + letter_index = 0 + for word in line.split(): + # Check color of each letter after word. + letter_index += len(word) + if letter_index >= len(line): + # Stop checking at the end of the line. + break + run = paragraph.runs[letter_index] + if all([len(run.text) == 1, run.text != ' ']): + self.assertEqual(run.font.color.rgb, RGBColor(255, 255, 255)) + count += 1 + letter_index += 1 + paragraph_index += 1 + os.remove(output_file) + # Test error. + ciphertext = ciphertext[:-1] + faketext = invisible_ink.get_text(fakefile)[2:] + error = 'Need 25 more spaces in the plaintext (fake) message.' + with self.assertRaises(ValueError) as err: + invisible_ink_mono.write_invisible(faketext, ciphertext) + self.assertEqual(error, str(err.exception)) + + @unittest.mock.patch('src.ch06.c1_invisible_ink_mono.Path.resolve') + @unittest.mock.patch('sys.stdout', new_callable=StringIO) + def test_main(self, mock_stdout, mock_resolve): + """Test demo main function.""" + # Mock output of abspath to avoid FileNotFoundError. + mock_resolve.return_value = os.path.normpath('src/ch06/c1files') + current_dir = os.getcwd() + # Test using test files. + fakefile = os.path.join(current_dir, 'tests/data/ch06/fake_mono.docx') + cipherfile = os.path.join(current_dir, 'tests/data/ch06/cipher_mono.docx') + output_file = os.path.join(current_dir, 'tests/data/ch06/output.docx') + invisible_ink_mono.main(fakefile, cipherfile, output_file) + self.assertTrue(os.path.exists(output_file)) + output_text = invisible_ink.get_text(output_file) + self.assertEqual(constants.MAIN_TEST_MONO, output_text) + os.remove(output_file) + # Test printed output. + with open(os.path.normpath('tests/data/ch06/main/invisible_ink_mono.txt'), + 'r') as file: + file_data = ''.join(file.readlines()) + self.assertEqual(mock_stdout.getvalue(), file_data) + # Test using default files. + invisible_ink_mono.main() + output_file = os.path.normpath('src/ch06/c1files/DearInternet.docx') + self.assertTrue(os.path.exists(output_file)) + output_text = invisible_ink.get_text(output_file) + self.assertEqual(constants.MAIN_DEFAULT_MONO, output_text) + os.remove(output_file) + + if __name__ == '__main__': unittest.main()