├── requirements.txt ├── README.md ├── LICENSE ├── test.sh └── signal-wont-let-me-attach.py /requirements.txt: -------------------------------------------------------------------------------- 1 | Pillow >= 2.0.0 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **Update:** Signal [now][1] allows to send arbitrary file types! 2 | 3 | [1]: https://twitter.com/whispersystems/status/859125874901135360 4 | 5 | --- 6 | 7 | signal-wont-let-me-attach 8 | ========================= 9 | 10 | Store arbitrary files inside PNGs to overcome nonsensical file type 11 | restrictions. 12 | 13 | ![Demo](http://i.imgur.com/4S9wEoo.png) 14 | 15 | Examples 16 | -------- 17 | 18 | Pack `foo.pdf` into `foo.png`: 19 | 20 | ```console 21 | signal-wont-let-me-attach foo.pdf 22 | ``` 23 | 24 | Pack `foo.pdf` into `bar.png`: 25 | 26 | ```console 27 | signal-wont-let-me-attach foo.pdf bar.png 28 | ``` 29 | 30 | Unpack and delete `file.png` restoring the original file: 31 | 32 | ```console 33 | signal-wont-let-me-attach file.png 34 | ``` 35 | 36 | Setup 37 | ----- 38 | 39 | Install dependencies: 40 | 41 | ```console 42 | pip install -r requirements.txt 43 | ``` 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Andrea Cardaci 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e -x 4 | 5 | script="$PWD/signal-wont-let-me-attach.py" 6 | 7 | run_test() { 8 | local python="$1" 9 | local name="$2" 10 | local output="$3" 11 | input="$(mktemp '/tmp/'"$name"'-XXXXX')" 12 | cd "$(dirname "$input")" 13 | yes dummy | nl | head -c $((1024 * 1024)) >"$input" 14 | if [ -z "$output" ]; then 15 | "$python" "$script" "$input" 16 | output="$input.png" 17 | else 18 | "$python" "$script" "$input" "$output" 19 | fi 20 | file "$output" | grep -q 'PNG image data' 21 | mv "$input" "$input.bak" 22 | "$python" "$script" "$output" 23 | cmp "$input" "$input.bak" 24 | rm "$input" "$input.bak" 25 | cd - 26 | } 27 | 28 | # check PEP 8 compliance 29 | hash pep8 && pep8 "$script" 30 | 31 | # test wrong arguments 32 | "$script" &>/dev/null && exit 1 || true 33 | "$script" a b c &>/dev/null && exit 1 || true 34 | "$script" a.png b &>/dev/null && exit 1 || true 35 | 36 | # test file not found 37 | "$script" not_exists && exit 1 || true 38 | "$script" not_exists not_exists.png && exit 1 || true 39 | "$script" not_exists.png && exit 1 || true 40 | 41 | # test features 42 | for python in python2 python3; do 43 | run_test "$python" 'file_in' 44 | run_test "$python" 'FÌLÈ_IN' 45 | run_test "$python" 'file_in' 'file_out.png' 46 | run_test "$python" 'FÌLÈ_IN' 'file_out.png' 47 | run_test "$python" 'file_in' 'FÌLÈ_OUT.png' 48 | run_test "$python" 'FÌLÈ_IN' 'FÌLÈ_OUT.png' 49 | done 50 | -------------------------------------------------------------------------------- /signal-wont-let-me-attach.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import codecs 4 | import io 5 | import itertools 6 | import math 7 | import os 8 | import struct 9 | import sys 10 | 11 | from PIL import Image 12 | 13 | 14 | __prefix_format = '!I' 15 | __prefix_size = struct.calcsize(__prefix_format) 16 | 17 | 18 | def __build_payload(name, data): 19 | # store name and data using two size-value records 20 | name_prefix = struct.pack(__prefix_format, len(name)) 21 | data_prefix = struct.pack(__prefix_format, len(data)) 22 | payload = bytearray() 23 | payload.extend(name_prefix) 24 | payload.extend(name) 25 | payload.extend(data_prefix) 26 | payload.extend(data) 27 | return payload 28 | 29 | 30 | def __parse_payload(payload): 31 | # extract the file name 32 | name_size = struct.unpack(__prefix_format, payload[:__prefix_size])[0] 33 | payload = payload[__prefix_size:] 34 | name = payload[:name_size].decode('utf-8') 35 | payload = payload[name_size:] 36 | # extract the file data 37 | data_size = struct.unpack(__prefix_format, payload[:__prefix_size])[0] 38 | payload = payload[__prefix_size:] 39 | data = codecs.decode(payload[:data_size], 'zlib') 40 | return name, data 41 | 42 | 43 | def __measure_container(size): 44 | # compute the appropriate extents in order to contain size 45 | n_planes = 4 # RGBA 46 | side = int(math.ceil(math.sqrt(size / n_planes))) 47 | capacity = (side ** 2) * n_planes 48 | return (side, capacity) 49 | 50 | 51 | def __load_file(path): 52 | # fetch the name as a byte string 53 | name = os.path.basename(path) 54 | if sys.version_info >= (3,): 55 | name = name.encode('utf-8') 56 | # read anz compress the file content 57 | with open(path, 'rb') as content_file: 58 | data = codecs.encode(content_file.read(), 'zlib') 59 | return (name, data) 60 | 61 | 62 | def __write_file(name, data): 63 | with open(name, 'wb') as content_file: 64 | content_file.write(data) 65 | return name 66 | 67 | 68 | def __load_image(path): 69 | # fetch image data as raw bytes 70 | with Image.open(path) as image: 71 | integers = itertools.chain.from_iterable(image.getdata()) 72 | data = bytes(bytearray(integers)) 73 | return data 74 | 75 | 76 | def __save_image(path, data, side): 77 | mode = 'RGBA' 78 | size = (side, side) 79 | with Image.frombytes(mode, size, data, decoder_name='raw') as image: 80 | image.save(path, 'PNG') 81 | 82 | 83 | def pack(image_path, content_path): 84 | name, data = __load_file(content_path) 85 | payload = __build_payload(name, data) 86 | side, capacity = __measure_container(len(payload)) 87 | payload.extend(b'\x00' * (capacity - len(payload))) 88 | __save_image(image_path, bytes(payload), side) 89 | 90 | 91 | def unpack(image_path): 92 | payload = __load_image(image_path) 93 | name, data = __parse_payload(payload) 94 | __write_file(name, data) 95 | return name 96 | 97 | 98 | def __usage(): 99 | usage_string = '''Usage: 100 | 101 | attempt unpack and delete the PNG 102 | [] pack the file into the PNG 103 | ''' 104 | sys.stderr.write(usage_string) 105 | sys.exit(1) 106 | 107 | 108 | def __pack_action(image_path, content_path): 109 | pack(image_path, content_path) 110 | print(image_path) 111 | 112 | 113 | def __unpack_action(image_path): 114 | content_name = unpack(image_path) 115 | print(content_name) 116 | os.unlink(image_path) 117 | 118 | 119 | def __main(args): 120 | # check arguments 121 | if len(args) not in (1, 2): 122 | __usage() 123 | # extract path components 124 | input_path = args[0] 125 | input_base, input_ext = os.path.splitext(input_path) 126 | # operate according to the extension 127 | if input_ext.lower() == '.png': 128 | # attempt to unpack then delete the PNG 129 | if len(args) != 1: 130 | __usage() 131 | else: 132 | __unpack_action(input_path) 133 | else: 134 | # pack the file into the PNG 135 | if len(args) == 2: 136 | image_path = args[1] 137 | else: 138 | image_path = '{}.png'.format(input_base) 139 | __pack_action(image_path, input_path) 140 | 141 | 142 | if __name__ == '__main__': 143 | try: 144 | __main(sys.argv[1:]) 145 | except (FileNotFoundError, IsADirectoryError, PermissionError) as e: 146 | sys.stderr.write('{}\n'.format(str(e))) 147 | sys.exit(1) 148 | --------------------------------------------------------------------------------