Skip to content

Commit ad2a0bc

Browse files
authored
Merge pull request #145 from realpython/bitwise-operators
Python Bitwise Operators (materials)
2 parents 5968f73 + ebccee0 commit ad2a0bc

File tree

8 files changed

+334
-0
lines changed

8 files changed

+334
-0
lines changed

python-bitwise-operators/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Bitwise Operators in Python
2+
3+
Source code of the least-significant bit **steganography** example from the [Bitwise Operators in Python](https://realpython.com/python-bitwise-operators/) article.
4+
5+
## Installation
6+
7+
There are no external dependencies except for a Python interpreter.
8+
9+
## Running
10+
11+
Change directory to the current folder, where this `README.md` file is located, and then execute Python module:
12+
13+
```shell
14+
$ python -m stegano /path/to/bitmap (--encode /path/to/file | --decode | --erase)
15+
```
16+
17+
For example, to extract a secret file from the attached bitmap, type the following:
18+
19+
```shell
20+
$ python -m stegano example.bmp -d
21+
Extracted a secret file: podcast.mp4
22+
```
4.06 MB
Binary file not shown.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
Runnable module, can be executed as:
3+
$ python -m stegano
4+
"""
5+
6+
from .bitmap import Bitmap
7+
from .cli import CommandLineArguments, parse_args
8+
from .decoder import decode, DecodingError
9+
from .encoder import encode, EncodingError
10+
from .eraser import erase
11+
12+
13+
def main(args: CommandLineArguments) -> None:
14+
"""Entry point to the script."""
15+
with Bitmap(args.bitmap) as bitmap:
16+
if args.encode:
17+
encode(bitmap, args.encode)
18+
elif args.decode:
19+
decode(bitmap)
20+
elif args.erase:
21+
erase(bitmap)
22+
23+
24+
if __name__ == "__main__":
25+
try:
26+
main(parse_args())
27+
except (EncodingError, DecodingError) as ex:
28+
print(ex)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
"""
2+
Bitmap read/write operations.
3+
"""
4+
5+
import pathlib
6+
from dataclasses import dataclass
7+
from itertools import islice
8+
from mmap import mmap, ACCESS_WRITE
9+
from struct import pack, unpack
10+
from typing import Any, Union, Iterator
11+
12+
13+
class Bitmap:
14+
"""High-level interface to a bitmap file."""
15+
16+
def __init__(self, path: pathlib.Path) -> None:
17+
self._file = path.open(mode="r+b")
18+
self._file_bytes = mmap(self._file.fileno(), 0, access=ACCESS_WRITE)
19+
self._header = Header.from_bytes(self._file_bytes[:50])
20+
21+
def __enter__(self) -> "Bitmap":
22+
return self
23+
24+
def __exit__(self, *args, **kwargs) -> None:
25+
self._file_bytes.close()
26+
self._file.close()
27+
28+
def __getattr__(self, name: str) -> Any:
29+
return getattr(self._header, name)
30+
31+
def __getitem__(self, offset: Union[int, slice]) -> Union[int, bytes]:
32+
return self._file_bytes[offset]
33+
34+
def __setitem__(
35+
self, offset: Union[int, slice], value: Union[int, bytes]
36+
) -> None:
37+
self._file_bytes[offset] = value
38+
39+
@property
40+
def max_bytes(self) -> int:
41+
"""The maximum number of bytes the bitmap can hide."""
42+
return self.width * self.height * 3
43+
44+
@property
45+
def byte_offsets(self) -> Iterator[int]:
46+
"""Return an iterator over byte offsets (skip the padding)."""
47+
48+
start_index = self.pixels_offset
49+
end_index = self.pixels_offset + self.pixel_size_bytes
50+
scanline_bytes = self.pixel_size_bytes // self.height
51+
52+
for scanline in range(start_index, end_index, scanline_bytes):
53+
yield from range(scanline, scanline + self.width * 3)
54+
55+
@property
56+
def byte_slices(self) -> Iterator[slice]:
57+
"""Generator iterator of 8-byte long slices."""
58+
for byte_index in islice(self.byte_offsets, 0, self.max_bytes, 8):
59+
yield slice(byte_index, byte_index + 8)
60+
61+
@property
62+
def reserved_field(self) -> int:
63+
"""Return a little-endian 32-bit unsigned integer."""
64+
return unsigned_int(self._file_bytes, 0x06)
65+
66+
@reserved_field.setter
67+
def reserved_field(self, value: int) -> None:
68+
"""Store a little-endian 32-bit unsigned integer."""
69+
self._file_bytes.seek(0x06)
70+
self._file_bytes.write(pack("<I", value))
71+
72+
73+
@dataclass
74+
class Header:
75+
"""Bitmap metadata from the file header."""
76+
77+
signature: bytes
78+
file_size_bytes: int
79+
pixel_size_bytes: int
80+
pixels_offset: int
81+
width: int
82+
height: int
83+
bit_depth: int
84+
compressed: bool
85+
has_palette: bool
86+
87+
def __post_init__(self):
88+
assert self.signature == b"BM", "Unknown file signature"
89+
assert not self.compressed, "Compression unsupported"
90+
assert not self.has_palette, "Color palette unsupported"
91+
assert self.bit_depth == 24, "Only 24-bit depth supported"
92+
93+
@staticmethod
94+
def from_bytes(data: bytes) -> "Header":
95+
"""Factory method to deserialize the header from bytes."""
96+
return Header(
97+
signature=data[0x00:2],
98+
file_size_bytes=unsigned_int(data, 0x02),
99+
pixels_offset=unsigned_int(data, 0x0A),
100+
width=unsigned_int(data, 0x12),
101+
height=unsigned_int(data, 0x16),
102+
bit_depth=unsigned_short(data, 0x1C),
103+
compressed=unsigned_int(data, 0x1E) != 0,
104+
has_palette=unsigned_int(data, 0x2E) != 0,
105+
pixel_size_bytes=unsigned_int(data, 0x22),
106+
)
107+
108+
109+
def unsigned_int(data: Union[bytes, mmap], offset: int) -> int:
110+
"""Read a little-endian 32-bit unsigned integer."""
111+
return unpack("<I", data[offset : offset + 4])[0]
112+
113+
114+
def unsigned_short(data: Union[bytes, mmap], offset: int) -> int:
115+
"""Read a little-endian 16-bit unsigned integer."""
116+
return unpack("<H", data[offset : offset + 2])[0]
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""
2+
Command line arguments parsing.
3+
"""
4+
5+
import argparse
6+
import pathlib
7+
from dataclasses import dataclass
8+
from typing import Optional
9+
10+
11+
@dataclass
12+
class CommandLineArguments:
13+
"""Parsed command line arguments."""
14+
15+
bitmap: pathlib.Path
16+
encode: Optional[pathlib.Path]
17+
decode: bool
18+
erase: bool
19+
20+
21+
def parse_args() -> CommandLineArguments:
22+
"""Parse command line arguments."""
23+
24+
parser = argparse.ArgumentParser()
25+
parser.add_argument("bitmap", type=path_factory)
26+
27+
modes = parser.add_mutually_exclusive_group()
28+
modes.add_argument("--encode", "-e", metavar="file", type=path_factory)
29+
modes.add_argument("--decode", "-d", action="store_true")
30+
modes.add_argument("--erase", "-x", action="store_true")
31+
32+
args = parser.parse_args()
33+
34+
if not any([args.encode, args.decode, args.erase]):
35+
parser.error("Mode required: --encode file | --decode | --erase")
36+
37+
return CommandLineArguments(**vars(args))
38+
39+
40+
def path_factory(argument: str) -> pathlib.Path:
41+
"""Convert the argument to a path instance."""
42+
43+
path = pathlib.Path(argument)
44+
45+
if not path.exists():
46+
raise argparse.ArgumentTypeError("file doesn't exist")
47+
48+
if not path.is_file():
49+
raise argparse.ArgumentTypeError("must be a file")
50+
51+
return path
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""
2+
Secret file decoder.
3+
"""
4+
5+
from itertools import islice, takewhile
6+
from pathlib import Path
7+
from typing import Iterator
8+
9+
from .bitmap import Bitmap
10+
11+
12+
class DecodingError(Exception):
13+
pass
14+
15+
16+
def decode(bitmap: Bitmap) -> None:
17+
"""Extract a secret file from the bitmap."""
18+
19+
if bitmap.reserved_field <= 0:
20+
raise DecodingError("Secret file not found in the bitmap")
21+
22+
iterator = secret_bytes(bitmap)
23+
24+
filename = "".join(map(chr, takewhile(lambda x: x != 0, iterator)))
25+
with Path(filename).open(mode="wb") as file:
26+
file.write(bytes(islice(iterator, bitmap.reserved_field)))
27+
28+
print(f"Extracted a secret file: {filename}")
29+
30+
31+
def secret_bytes(bitmap) -> Iterator[int]:
32+
"""Return an iterator over secret bytes."""
33+
for eight_bytes in bitmap.byte_slices:
34+
yield sum(
35+
[
36+
(byte & 1) << (7 - i)
37+
for i, byte in enumerate(bitmap[eight_bytes])
38+
]
39+
)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""
2+
Secret file encoder.
3+
"""
4+
5+
import pathlib
6+
from typing import Iterator
7+
8+
from .bitmap import Bitmap
9+
10+
11+
class EncodingError(Exception):
12+
pass
13+
14+
15+
class SecretFile:
16+
"""Convenience class for serializing secret data."""
17+
18+
def __init__(self, path: pathlib.Path):
19+
self.path = path
20+
self.filename = path.name.encode("utf-8") + b"\x00"
21+
self.size_bytes = path.stat().st_size
22+
23+
@property
24+
def num_secret_bytes(self) -> int:
25+
"""Total number of bytes including the null-terminated string."""
26+
return len(self.filename) + self.size_bytes
27+
28+
@property
29+
def secret_bytes(self) -> Iterator[int]:
30+
"""Null-terminated name followed by the file content."""
31+
yield from self.filename
32+
with self.path.open(mode="rb") as file:
33+
yield from file.read()
34+
35+
36+
def encode(bitmap: Bitmap, path: pathlib.Path) -> None:
37+
"""Embed a secret file in the bitmap."""
38+
39+
file = SecretFile(path)
40+
41+
if file.num_secret_bytes > bitmap.max_bytes:
42+
raise EncodingError("Not enough pixels to embed a secret file")
43+
44+
bitmap.reserved_field = file.size_bytes
45+
for secret_byte, eight_bytes in zip(file.secret_bytes, bitmap.byte_slices):
46+
secret_bits = [(secret_byte >> i) & 1 for i in reversed(range(8))]
47+
bitmap[eight_bytes] = bytes(
48+
[
49+
byte | 1 if bit else byte & ~1
50+
for byte, bit in zip(bitmap[eight_bytes], secret_bits)
51+
]
52+
)
53+
54+
print("Secret file was embedded in the bitmap")
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""
2+
Secret file eraser.
3+
"""
4+
5+
from itertools import islice
6+
from random import random
7+
8+
from .bitmap import Bitmap
9+
10+
11+
def erase(bitmap: Bitmap) -> None:
12+
"""Scramble a previously hidden data."""
13+
if bitmap.reserved_field > 0:
14+
for byte_offset in islice(bitmap.byte_offsets, bitmap.reserved_field):
15+
bitmap[byte_offset] = randomize_lsb(bitmap[byte_offset])
16+
bitmap.reserved_field = 0
17+
print("Erased a secret file from the bitmap")
18+
else:
19+
print("Secret file not found in the bitmap")
20+
21+
22+
def randomize_lsb(value: int) -> int:
23+
"""Set a random bit on the least-significant position."""
24+
return value & ~1 if random() < 0.5 else value | 1

0 commit comments

Comments
 (0)