Pixseal 0.2.1.post2__cp313-cp313-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of Pixseal might be problematic. Click here for more details.

Pixseal/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ from .simpleImage import ImageInput, SimpleImage
2
+ from .imageSigner import (
3
+ BinaryProvider,
4
+ addHiddenBit,
5
+ signImage,
6
+ )
7
+ from .imageValidator import (
8
+ binaryToString,
9
+ buildValidationReport,
10
+ deduplicate,
11
+ readHiddenBit,
12
+ validateImage,
13
+ )
14
+
15
+ __all__ = [
16
+ "SimpleImage",
17
+ "ImageInput",
18
+ "BinaryProvider",
19
+ "addHiddenBit",
20
+ "signImage",
21
+ "binaryToString",
22
+ "buildValidationReport",
23
+ "deduplicate",
24
+ "readHiddenBit",
25
+ "validateImage",
26
+ ]
Pixseal/imageSigner.py ADDED
@@ -0,0 +1,191 @@
1
+ import base64
2
+ from pathlib import Path
3
+ from cryptography.hazmat.primitives import hashes, serialization
4
+ from cryptography.hazmat.primitives.asymmetric import padding
5
+
6
+ try:
7
+ from line_profiler import profile # type: ignore
8
+ except ImportError: # pragma: no cover
9
+ def profile(func):
10
+ return func
11
+
12
+ from .simpleImage import ImageInput, SimpleImage
13
+
14
+
15
+ class BinaryProvider:
16
+
17
+ # Constructor
18
+ def __init__(
19
+ self,
20
+ hiddenString,
21
+ startString="START-VALIDATION\n",
22
+ endString="\nEND-VALIDATION",
23
+ ):
24
+ self.hiddenBits = self._stringToBits(hiddenString)
25
+ self.startBits = self._stringToBits(startString)
26
+ self.endBits = self._stringToBits(endString)
27
+
28
+ # Convert string to contiguous binary digits
29
+ def _stringToBits(self, string):
30
+ bits = []
31
+ for char in string:
32
+ binary = format(ord(char), "08b")
33
+ bits.extend(int(bit) for bit in binary)
34
+ return bits
35
+
36
+ def _expandPayload(self, count: int):
37
+ if count <= 0:
38
+ return []
39
+ payloadLen = len(self.hiddenBits)
40
+ if payloadLen == 0:
41
+ raise ValueError("Hidden payload is empty")
42
+ repeats, remainder = divmod(count, payloadLen)
43
+ return (self.hiddenBits * repeats) + self.hiddenBits[:remainder]
44
+
45
+ def buildBitArray(self, pixelCount: int):
46
+ startLen = len(self.startBits)
47
+ endLen = len(self.endBits)
48
+ if pixelCount < startLen + endLen:
49
+ raise ValueError("Image is too small to fit start/end sentinels")
50
+
51
+ bits = [0] * pixelCount
52
+
53
+ # 1. Place START-VALIDATION bits first
54
+ bits[:startLen] = self.startBits
55
+
56
+ # 2. Fill the remaining slots by repeating the payload bits
57
+ payloadSlots = pixelCount - startLen
58
+ payloadBits = self._expandPayload(payloadSlots)
59
+ bits[startLen:] = payloadBits[:payloadSlots]
60
+
61
+ # 3. Overwrite the tail with END-VALIDATION bits
62
+ tailStart = pixelCount - endLen
63
+ bits[tailStart:] = self.endBits
64
+
65
+ return bits
66
+
67
+
68
+ @profile
69
+ def addHiddenBit(imageInput: ImageInput, hiddenBinary: BinaryProvider):
70
+ img = SimpleImage.open(imageInput)
71
+ width, height = img.size
72
+ pixels = img._pixels # direct buffer access for performance
73
+ total = width * height
74
+ payloadBits = hiddenBinary.buildBitArray(total)
75
+
76
+ # Iterate over every pixel and inject one bit
77
+ for idx in range(total):
78
+ base = idx * 3
79
+ # Read the pixel
80
+ r = pixels[base]
81
+ g = pixels[base + 1]
82
+ b = pixels[base + 2]
83
+
84
+ # Calculate the distance from 127
85
+ diffR = r - 127
86
+ if diffR < 0:
87
+ diffR = -diffR
88
+ diffG = g - 127
89
+ if diffG < 0:
90
+ diffG = -diffG
91
+ diffB = b - 127
92
+ if diffB < 0:
93
+ diffB = -diffB
94
+
95
+ # Pick the component farthest from 127
96
+ maxDiff = diffR
97
+ if diffG > maxDiff:
98
+ maxDiff = diffG
99
+ if diffB > maxDiff:
100
+ maxDiff = diffB
101
+
102
+ # Actual value of that channel
103
+ if maxDiff == diffR:
104
+ targetColorValue = r
105
+ elif maxDiff == diffG:
106
+ targetColorValue = g
107
+ else:
108
+ targetColorValue = b
109
+
110
+ # Channels >=127 are decremented, <127 incremented
111
+ addDirection = 1 if targetColorValue < 127 else -1
112
+
113
+ # Pull next bit from provider
114
+ bit = payloadBits[idx]
115
+
116
+ # Force the selected channel parity to match the bit
117
+ if maxDiff == diffR:
118
+ if r % 2 != bit:
119
+ r += addDirection
120
+ if maxDiff == diffG:
121
+ if g % 2 != bit:
122
+ g += addDirection
123
+ if maxDiff == diffB:
124
+ if b % 2 != bit:
125
+ b += addDirection
126
+
127
+ # Write the updated pixel
128
+ pixels[base] = r
129
+ pixels[base + 1] = g
130
+ pixels[base + 2] = b
131
+
132
+ # Return the modified image
133
+ return img
134
+
135
+
136
+ def stringCryptor(plaintext: str, public_key) -> str:
137
+
138
+ ciphertext = public_key.encrypt(
139
+ plaintext.encode("utf-8"),
140
+ padding.OAEP(
141
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
142
+ algorithm=hashes.SHA256(),
143
+ label=None,
144
+ ),
145
+ )
146
+
147
+ return base64.b64encode(ciphertext).decode("ascii")
148
+
149
+
150
+ # main
151
+ # Image input (path or bytes) + payload string => returns image with embedded payload
152
+ def signImage(imageInput: ImageInput, hiddenString, publicKeyPath=None):
153
+ """
154
+ Embed a payload into an image using the parity-based steganography scheme.
155
+
156
+ Args:
157
+ imageInput: File path, bytes, or file-like object accepted by SimpleImage.
158
+ hiddenString: Text payload that should be written into the image.
159
+ publicKeyPath: Optional path to a PEM-encoded RSA public key used to
160
+ encrypt the payload and sentinel markers before embedding.
161
+
162
+ Returns:
163
+ SimpleImage instance whose pixels include the signed payload.
164
+
165
+ Raises:
166
+ FileNotFoundError: If a public key path is provided but the file is missing.
167
+ ValueError: If the file is not a valid PEM public key.
168
+ """
169
+
170
+ if publicKeyPath: # When encryption key is supplied
171
+ key_path = Path(publicKeyPath)
172
+ if not key_path.is_file():
173
+ raise FileNotFoundError(f"Public key file not found: {publicKeyPath}")
174
+
175
+ pem_data = key_path.read_bytes()
176
+ if b"BEGIN PUBLIC KEY" not in pem_data:
177
+ raise ValueError("Provided file does not contain a valid public key")
178
+
179
+ public_key = serialization.load_pem_public_key(pem_data)
180
+
181
+ hiddenBinary = BinaryProvider(
182
+ hiddenString=stringCryptor(hiddenString, public_key) + "\n",
183
+ startString=stringCryptor("START-VALIDATION", public_key) + "\n",
184
+ endString="\n" + stringCryptor("END-VALIDATION", public_key),
185
+ )
186
+
187
+ else: # Plain-text payload
188
+ hiddenBinary = BinaryProvider(hiddenString + "\n")
189
+
190
+ signedImage = addHiddenBit(imageInput, hiddenBinary)
191
+ return signedImage
@@ -0,0 +1,206 @@
1
+ import base64
2
+ from cryptography.hazmat.primitives import serialization, hashes
3
+ from cryptography.hazmat.primitives.asymmetric import padding
4
+
5
+ try:
6
+ from line_profiler import profile # type: ignore
7
+ except ImportError: # pragma: no cover
8
+ def profile(func):
9
+ return func
10
+
11
+ from .simpleImage import ImageInput, SimpleImage
12
+
13
+
14
+ def binaryToString(binaryCode):
15
+ string = []
16
+ for i in range(0, len(binaryCode), 8):
17
+ byte = binaryCode[i : i + 8]
18
+ decimal = int(byte, 2)
19
+ character = chr(decimal)
20
+ string.append(character)
21
+ return "".join(string)
22
+
23
+
24
+ @profile
25
+ def readHiddenBit(imageInput: ImageInput):
26
+ img = SimpleImage.open(imageInput)
27
+ width, height = img.size
28
+ pixels = img._pixels # direct buffer access for performance
29
+ total = width * height
30
+ bits = []
31
+ append_bit = bits.append
32
+
33
+ for idx in range(total):
34
+ base = idx * 3
35
+ r = pixels[base]
36
+ g = pixels[base + 1]
37
+ b = pixels[base + 2]
38
+
39
+ diffR = r - 127
40
+ if diffR < 0:
41
+ diffR = -diffR
42
+ diffG = g - 127
43
+ if diffG < 0:
44
+ diffG = -diffG
45
+ diffB = b - 127
46
+ if diffB < 0:
47
+ diffB = -diffB
48
+
49
+ maxDiff = diffR
50
+ if diffG > maxDiff:
51
+ maxDiff = diffG
52
+ if diffB > maxDiff:
53
+ maxDiff = diffB
54
+
55
+ append_bit("1" if maxDiff % 2 == 0 else "0")
56
+
57
+ return "".join(bits)
58
+
59
+
60
+ def deduplicate(arr):
61
+ deduplicated = []
62
+ freq = {}
63
+ most_common = None
64
+ most_count = 0
65
+
66
+ for i, value in enumerate(arr):
67
+ freq[value] = freq.get(value, 0) + 1
68
+ if freq[value] > most_count:
69
+ most_count = freq[value]
70
+ most_common = value
71
+
72
+ if i == 0 or value != arr[i - 1]:
73
+ deduplicated.append(value)
74
+
75
+ return deduplicated, most_common
76
+
77
+
78
+ def tailCheck(arr: list[str]):
79
+ if len(arr) != 4:
80
+ return None # Not required
81
+
82
+ full_cipher = arr[1] # complete ciphertext
83
+ truncated_cipher = arr[2] # incomplete ciphertext
84
+
85
+ return full_cipher.startswith(truncated_cipher)
86
+
87
+
88
+ def buildValidationReport(decrypted, tailCheck: bool, skipPlain: bool = False):
89
+ # Length after deduplication/decryption
90
+ arrayLength = len(decrypted)
91
+
92
+ # 1. Check that the deduplicated sequence length is valid
93
+ lengthCheck = arrayLength in (3, 4)
94
+
95
+ # 2. Validate start/end markers
96
+ startCheck = decrypted[0] == "START-VALIDATION" if decrypted else False
97
+ endCheck = decrypted[-1] == "END-VALIDATION" if decrypted else False
98
+
99
+ # 4. Determine whether payload was successfully decrypted
100
+ decryptedPayload = decrypted[1] if len(decrypted) > 1 else ""
101
+ isDecrypted = bool(decryptedPayload) and not decryptedPayload.endswith("==")
102
+
103
+ checkList = [lengthCheck, startCheck, endCheck, isDecrypted]
104
+ # 5. Parse tailCheck result
105
+ if tailCheck is None:
106
+ tailCheckResult = "Not Required"
107
+ else:
108
+ tailCheckResult = tailCheck
109
+ checkList.append(tailCheckResult)
110
+
111
+ # Overall verdict requires every check to pass
112
+ verdict = all(checkList)
113
+
114
+ result = {
115
+ "arrayLength": arrayLength,
116
+ "lengthCheck": lengthCheck,
117
+ "startCheck": startCheck,
118
+ "endCheck": endCheck,
119
+ "isDecrypted": isDecrypted,
120
+ "tailCheckResult": tailCheckResult,
121
+ "verdict": verdict,
122
+ }
123
+
124
+ if skipPlain:
125
+ result["decryptSkipMessage"] = (
126
+ "Skip decrypt: payload was plain or corrupted text despite decrypt request."
127
+ )
128
+
129
+ return result
130
+
131
+
132
+ def decrypt_array(deduplicated, privKeyPath):
133
+ # Load PEM private key
134
+ with open(privKeyPath, "rb") as key_file:
135
+ private_key = serialization.load_pem_private_key(
136
+ key_file.read(),
137
+ password=None,
138
+ )
139
+
140
+ decrypted = []
141
+ skippedPlainCount = 0
142
+ decryptError = False
143
+ for item in deduplicated:
144
+ if item.endswith("=="):
145
+ try:
146
+ cipher_bytes = base64.b64decode(item)
147
+ plain_bytes = private_key.decrypt(
148
+ cipher_bytes,
149
+ padding.OAEP(
150
+ mgf=padding.MGF1(algorithm=hashes.SHA256()),
151
+ algorithm=hashes.SHA256(),
152
+ label=None,
153
+ ),
154
+ )
155
+ decrypted.append(plain_bytes.decode("utf-8"))
156
+ except Exception as exc:
157
+ print(exc)
158
+ decryptError = True
159
+ decrypted.append(item)
160
+ else:
161
+ skippedPlainCount += 1
162
+ decrypted.append(item)
163
+
164
+ expectedPlainCount = 0
165
+ if len(deduplicated) == 4:
166
+ expectedPlainCount = 1
167
+
168
+ skippedPlain = decryptError or skippedPlainCount != expectedPlainCount
169
+
170
+ return decrypted, skippedPlain
171
+
172
+
173
+ # main
174
+ def validateImage(imageInput: ImageInput, privKeyPath=None):
175
+ """
176
+ Extract the embedded payload from an image and optionally decrypt it.
177
+
178
+ Args:
179
+ imageInput: File path, bytes, or file-like object accepted by SimpleImage.
180
+ privKeyPath: Optional path to a PEM-encoded RSA private key used to
181
+ decrypt the extracted ciphertext.
182
+
183
+ Returns:
184
+ Dict with the most common extracted string, decrypted sequence, and
185
+ a validation report describing the sentinel checks and verdict.
186
+ """
187
+ resultBinary = readHiddenBit(imageInput)
188
+ resultString = binaryToString(resultBinary)
189
+ splited = resultString.split("\n")
190
+ deduplicated, most_common = deduplicate(splited)
191
+
192
+ if privKeyPath:
193
+ decrypted, skippedPlain = decrypt_array(deduplicated, privKeyPath)
194
+ else:
195
+ decrypted = deduplicated
196
+ skippedPlain = False
197
+
198
+ report = buildValidationReport(
199
+ decrypted=decrypted, tailCheck=tailCheck(deduplicated), skipPlain=skippedPlain
200
+ )
201
+
202
+ return {
203
+ "extractedString": decrypt_array({most_common}, privKeyPath)[0][0],
204
+ "decrypted": decrypted,
205
+ "validationReport": report,
206
+ }
Pixseal/simpleImage.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ Loader that prefers the Cython implementation and falls back to pure Python.
3
+ Use environment variable PIXSEAL_SIMPLEIMAGE_BACKEND to force a backend:
4
+ - "cython": require compiled extension
5
+ - "python": force pure Python implementation
6
+ """
7
+
8
+ from os import getenv
9
+
10
+ _backend = getenv("PIXSEAL_SIMPLEIMAGE_BACKEND", "auto").lower()
11
+ _loaded_module = "Pixseal.simpleImage_py"
12
+
13
+ if _backend == "python":
14
+ from . import simpleImage_py as _impl # type: ignore
15
+ elif _backend == "cython":
16
+ try:
17
+ from . import simpleImage_ext as _impl # type: ignore
18
+ except ImportError as exc: # pragma: no cover
19
+ raise ImportError(
20
+ "Cython backend requested but Pixseal.simpleImage_ext is not available. "
21
+ "Build the extension or choose the python backend."
22
+ ) from exc
23
+ else:
24
+ try: # pragma: no cover
25
+ from . import simpleImage_ext as _impl # type: ignore
26
+ except ImportError: # pragma: no cover
27
+ from . import simpleImage_py as _impl # type: ignore
28
+
29
+ SimpleImage = _impl.SimpleImage # type: ignore
30
+ ImageInput = _impl.ImageInput # type: ignore
31
+ _loaded_module = _impl.__name__
32
+
33
+ print(f"[SimpleImage] Loaded from: {_loaded_module}")
34
+
35
+ __all__ = ["SimpleImage", "ImageInput"]
@@ -0,0 +1,319 @@
1
+ import struct
2
+ import zlib
3
+ from io import BytesIO
4
+ from pathlib import Path
5
+ from typing import List, Sequence, Tuple, Union
6
+
7
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
8
+
9
+
10
+ def _paethPredictor(a: int, b: int, c: int) -> int:
11
+ p = a + b - c
12
+ pa = abs(p - a)
13
+ pb = abs(p - b)
14
+ pc = abs(p - c)
15
+ if pa <= pb and pa <= pc:
16
+ return a
17
+ if pb <= pc:
18
+ return b
19
+ return c
20
+
21
+
22
+ def _applyPngFilter(
23
+ filterType: int, rowData: bytearray, prevRow: Sequence[int], bytesPerPixel: int
24
+ ) -> bytearray:
25
+ recon = bytearray(len(rowData))
26
+ for i in range(len(rowData)):
27
+ left = recon[i - bytesPerPixel] if i >= bytesPerPixel else 0
28
+ up = prevRow[i] if prevRow else 0
29
+ upLeft = prevRow[i - bytesPerPixel] if (prevRow and i >= bytesPerPixel) else 0
30
+
31
+ if filterType == 0:
32
+ recon[i] = rowData[i]
33
+ elif filterType == 1:
34
+ recon[i] = (rowData[i] + left) & 0xFF
35
+ elif filterType == 2:
36
+ recon[i] = (rowData[i] + up) & 0xFF
37
+ elif filterType == 3:
38
+ recon[i] = (rowData[i] + ((left + up) >> 1)) & 0xFF
39
+ elif filterType == 4:
40
+ recon[i] = (rowData[i] + _paethPredictor(left, up, upLeft)) & 0xFF
41
+ else:
42
+ raise ValueError(f"Unsupported PNG filter: {filterType}")
43
+ return recon
44
+
45
+
46
+ def _readChunk(stream) -> Tuple[bytes, bytes]:
47
+ lengthBytes = stream.read(4)
48
+ if len(lengthBytes) == 0:
49
+ return b"", b""
50
+ if len(lengthBytes) != 4:
51
+ raise ValueError("Unexpected EOF while reading chunk length")
52
+ length = struct.unpack(">I", lengthBytes)[0]
53
+ chunkType = stream.read(4)
54
+ if len(chunkType) != 4:
55
+ raise ValueError("Unexpected EOF while reading chunk type")
56
+ data = stream.read(length)
57
+ if len(data) != length:
58
+ raise ValueError("Unexpected EOF while reading chunk data")
59
+ crc = stream.read(4)
60
+ if len(crc) != 4:
61
+ raise ValueError("Unexpected EOF while reading chunk CRC")
62
+ expectedCrc = zlib.crc32(chunkType)
63
+ expectedCrc = zlib.crc32(data, expectedCrc) & 0xFFFFFFFF
64
+ actualCrc = struct.unpack(">I", crc)[0]
65
+ if actualCrc != expectedCrc:
66
+ raise ValueError("Corrupted PNG chunk detected")
67
+ return chunkType, data
68
+
69
+
70
+ def _loadPng(stream) -> Tuple[int, int, bytearray]:
71
+ signature = stream.read(8)
72
+ if signature != PNG_SIGNATURE:
73
+ raise ValueError("Unsupported PNG signature")
74
+
75
+ width = height = None
76
+ bitDepth = colorType = None
77
+ compression = filterMethod = interlace = None
78
+ idatChunks: List[bytes] = []
79
+
80
+ while True:
81
+ chunkType, data = _readChunk(stream)
82
+ if chunkType == b"":
83
+ break
84
+ if chunkType == b"IHDR":
85
+ (
86
+ width,
87
+ height,
88
+ bitDepth,
89
+ colorType,
90
+ compression,
91
+ filterMethod,
92
+ interlace,
93
+ ) = struct.unpack(">IIBBBBB", data)
94
+ elif chunkType == b"IDAT":
95
+ idatChunks.append(data)
96
+ elif chunkType == b"IEND":
97
+ break
98
+
99
+ if None in (
100
+ width,
101
+ height,
102
+ bitDepth,
103
+ colorType,
104
+ compression,
105
+ filterMethod,
106
+ interlace,
107
+ ):
108
+ raise ValueError("Incomplete PNG header information")
109
+ if bitDepth != 8:
110
+ raise ValueError("Only 8-bit PNG images are supported")
111
+ if colorType not in (2, 6):
112
+ raise ValueError("Only RGB/RGBA PNG images are supported")
113
+ if compression != 0 or filterMethod != 0 or interlace != 0:
114
+ raise ValueError("Unsupported PNG configuration (compression/filter/interlace)")
115
+
116
+ rawImage = zlib.decompress(b"".join(idatChunks))
117
+ bytesPerPixel = 3 if colorType == 2 else 4
118
+ rowLength = width * bytesPerPixel
119
+ expected = height * (rowLength + 1)
120
+ if len(rawImage) != expected:
121
+ raise ValueError("Malformed PNG image data")
122
+
123
+ pixels = bytearray(width * height * 3)
124
+ prevRow = bytearray(rowLength)
125
+ offset = 0
126
+ for y in range(height):
127
+ filterType = rawImage[offset]
128
+ offset += 1
129
+ rowBytes = bytearray(rawImage[offset : offset + rowLength])
130
+ offset += rowLength
131
+ recon = _applyPngFilter(filterType, rowBytes, prevRow, bytesPerPixel)
132
+ for x in range(width):
133
+ srcIndex = x * bytesPerPixel
134
+ destIndex = (y * width + x) * 3
135
+ pixels[destIndex] = recon[srcIndex]
136
+ pixels[destIndex + 1] = recon[srcIndex + 1]
137
+ pixels[destIndex + 2] = recon[srcIndex + 2]
138
+ prevRow = recon
139
+ return width, height, pixels
140
+
141
+
142
+ def _loadBmp(stream) -> Tuple[int, int, bytearray]:
143
+ header = stream.read(14)
144
+ if len(header) != 14 or header[:2] != b"BM":
145
+ raise ValueError("Unsupported BMP header")
146
+ fileSize, _, _, pixelOffset = struct.unpack("<IHHI", header[2:])
147
+ dibHeaderSizeBytes = stream.read(4)
148
+ if len(dibHeaderSizeBytes) != 4:
149
+ raise ValueError("Corrupted BMP DIB header")
150
+ dibHeaderSize = struct.unpack("<I", dibHeaderSizeBytes)[0]
151
+ if dibHeaderSize != 40:
152
+ raise ValueError("Only BITMAPINFOHEADER BMP files are supported")
153
+ dibData = stream.read(36)
154
+ (
155
+ width,
156
+ height,
157
+ planes,
158
+ bitCount,
159
+ compression,
160
+ imageSize,
161
+ xPpm,
162
+ yPpm,
163
+ clrUsed,
164
+ clrImportant,
165
+ ) = struct.unpack("<iiHHIIiiII", dibData)
166
+ if planes != 1 or bitCount != 24 or compression != 0:
167
+ raise ValueError("Only uncompressed 24-bit BMP files are supported")
168
+ absHeight = abs(height)
169
+ rowStride = ((width * 3 + 3) // 4) * 4
170
+ pixels = bytearray(width * absHeight * 3)
171
+ stream.seek(pixelOffset)
172
+ for row in range(absHeight):
173
+ rowData = stream.read(rowStride)
174
+ if len(rowData) != rowStride:
175
+ raise ValueError("Incomplete BMP pixel data")
176
+ targetRow = absHeight - 1 - row if height > 0 else row
177
+ baseIndex = targetRow * width * 3
178
+ for x in range(width):
179
+ pixelOffsetInRow = x * 3
180
+ b = rowData[pixelOffsetInRow]
181
+ g = rowData[pixelOffsetInRow + 1]
182
+ r = rowData[pixelOffsetInRow + 2]
183
+ dest = baseIndex + x * 3
184
+ pixels[dest] = r & 0xFF
185
+ pixels[dest + 1] = g & 0xFF
186
+ pixels[dest + 2] = b & 0xFF
187
+ return width, absHeight, pixels
188
+
189
+
190
+ def _makeChunk(chunkType: bytes, data: bytes) -> bytes:
191
+ length = struct.pack(">I", len(data))
192
+ crcValue = zlib.crc32(chunkType)
193
+ crcValue = zlib.crc32(data, crcValue) & 0xFFFFFFFF
194
+ crc = struct.pack(">I", crcValue)
195
+ return length + chunkType + data + crc
196
+
197
+
198
+ def _writePng(path: str, width: int, height: int, pixels: Sequence[int]) -> None:
199
+ ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
200
+ rowStride = width * 3
201
+ raw = bytearray()
202
+ for y in range(height):
203
+ raw.append(0)
204
+ start = y * rowStride
205
+ raw.extend(pixels[start : start + rowStride])
206
+ compressed = zlib.compress(bytes(raw))
207
+ with open(path, "wb") as output:
208
+ output.write(PNG_SIGNATURE)
209
+ output.write(_makeChunk(b"IHDR", ihdr))
210
+ output.write(_makeChunk(b"IDAT", compressed))
211
+ output.write(_makeChunk(b"IEND", b""))
212
+
213
+
214
+ def _writeBmp(path: str, width: int, height: int, pixels: Sequence[int]) -> None:
215
+ rowStride = ((width * 3 + 3) // 4) * 4
216
+ pixelArraySize = rowStride * height
217
+ fileSize = 14 + 40 + pixelArraySize
218
+ with open(path, "wb") as output:
219
+ output.write(b"BM")
220
+ output.write(struct.pack("<IHHI", fileSize, 0, 0, 54))
221
+ output.write(
222
+ struct.pack(
223
+ "<IIIHHIIIIII",
224
+ 40,
225
+ width,
226
+ height,
227
+ 1,
228
+ 24,
229
+ 0,
230
+ pixelArraySize,
231
+ 2835,
232
+ 2835,
233
+ 0,
234
+ 0,
235
+ )
236
+ )
237
+ rowPad = rowStride - width * 3
238
+ padBytes = b"\x00" * rowPad
239
+ for y in range(height - 1, -1, -1):
240
+ start = y * width * 3
241
+ for x in range(width):
242
+ idx = start + x * 3
243
+ r = pixels[idx]
244
+ g = pixels[idx + 1]
245
+ b = pixels[idx + 2]
246
+ output.write(bytes((b & 0xFF, g & 0xFF, r & 0xFF)))
247
+ if rowPad:
248
+ output.write(padBytes)
249
+
250
+
251
+ ImageInput = Union[str, Path, bytes, bytearray]
252
+
253
+
254
+ class SimpleImage:
255
+ __slots__ = ("width", "height", "_pixels")
256
+
257
+ def __init__(self, width: int, height: int, pixels: Sequence[int]):
258
+ self.width = width
259
+ self.height = height
260
+ expected = width * height * 3
261
+ if len(pixels) != expected:
262
+ raise ValueError("Pixel data length does not match image dimensions")
263
+ self._pixels = bytearray(pixels)
264
+
265
+ @property
266
+ def size(self) -> Tuple[int, int]:
267
+ return self.width, self.height
268
+
269
+ @staticmethod
270
+ def _streamToImage(stream) -> Tuple[int, int, bytearray]:
271
+ signature = stream.read(8)
272
+ stream.seek(0)
273
+ if signature.startswith(PNG_SIGNATURE):
274
+ return _loadPng(stream)
275
+ if signature[:2] == b"BM":
276
+ return _loadBmp(stream)
277
+ raise ValueError("Unsupported image format")
278
+
279
+ @classmethod
280
+ def open(cls, source: ImageInput) -> "SimpleImage":
281
+ if isinstance(source, (str, Path)):
282
+ with open(source, "rb") as stream:
283
+ width, height, pixels = cls._streamToImage(stream)
284
+ elif isinstance(source, (bytes, bytearray)):
285
+ stream = BytesIO(source)
286
+ width, height, pixels = cls._streamToImage(stream)
287
+ else:
288
+ raise TypeError("source must be a file path or raw bytes")
289
+ return cls(width, height, pixels)
290
+
291
+ def getPixel(self, coords: Tuple[int, int]) -> Tuple[int, int, int]:
292
+ x, y = coords
293
+ if not (0 <= x < self.width and 0 <= y < self.height):
294
+ raise ValueError("Pixel coordinate out of bounds")
295
+ index = (y * self.width + x) * 3
296
+ return (
297
+ self._pixels[index],
298
+ self._pixels[index + 1],
299
+ self._pixels[index + 2],
300
+ )
301
+
302
+ def putPixel(self, coords: Tuple[int, int], value: Sequence[int]) -> None:
303
+ x, y = coords
304
+ if not (0 <= x < self.width and 0 <= y < self.height):
305
+ raise ValueError("Pixel coordinate out of bounds")
306
+ index = (y * self.width + x) * 3
307
+ r, g, b = value
308
+ self._pixels[index] = int(r) & 0xFF
309
+ self._pixels[index + 1] = int(g) & 0xFF
310
+ self._pixels[index + 2] = int(b) & 0xFF
311
+
312
+ def copy(self) -> "SimpleImage":
313
+ return SimpleImage(self.width, self.height, self._pixels[:])
314
+
315
+ def save(self, path: str) -> None:
316
+ _writePng(path, self.width, self.height, self._pixels)
317
+
318
+ def saveBmp(self, path: str) -> None:
319
+ _writeBmp(path, self.width, self.height, self._pixels)
@@ -0,0 +1,245 @@
1
+ Metadata-Version: 2.4
2
+ Name: Pixseal
3
+ Version: 0.2.1.post2
4
+ Summary: Encrypted Image Watermark Injector / Validator
5
+ Requires-Python: >=3.8
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: cryptography>=41.0.0
8
+
9
+ <p align="center">
10
+ <img src="https://raw.githubusercontent.com/kyj9447/Pixseal/main/assets/Pixseal.png" width="200px"/>
11
+ </p>
12
+
13
+ # Pixseal
14
+ ### Prove what you published — and what you didn’t.
15
+ Pixseal is a Python-based **image integrity and authenticity verification tool**
16
+ designed to **detect whether an image has been modified since signing.**
17
+
18
+ Pixseal embeds a **cryptographically verifiable integrity seal** into an image in an
19
+ invisible manner. During verification, **any modification** — including editing,
20
+ filtering, cropping, resizing, re-encoding — will cause
21
+ verification to **immediately fail**.
22
+
23
+ If **even a single pixel** is altered after signing, Pixseal will detect it.
24
+
25
+ Pixseal is not a visual watermarking or branding tool.
26
+ The watermark exists solely as a **means to achieve strict, deterministic image
27
+ tamper detection**.
28
+ Pixseal prioritizes tamper sensitivity over robustness against intentional adversarial manipulation.
29
+
30
+ - GitHub: https://github.com/kyj9447/Pixseal
31
+ - Changelog: https://github.com/kyj9447/Pixseal/blob/main/CHANGELOG.md
32
+
33
+ ## Features
34
+ - **Image Integrity Verification**
35
+ - Cryptographically proves that an image remains in its original, unmodified state
36
+ - Detects single-pixel changes with deterministic verification results
37
+
38
+ - **Tamper Detection**
39
+ - Detects all forms of image modification, including:
40
+ - editing
41
+ - filters and color adjustments
42
+ - cropping and resizing
43
+ - re-encoding and recompression
44
+ - pixel-level changes
45
+
46
+ - **Invisible Integrity Seal**
47
+ - Embeds verification data without any visible watermark
48
+ - Preserves the original visual appearance of the image
49
+
50
+ - **RSA-Based Encryption (Optional)**
51
+ - Supports RSA public/private key encryption for embedded verification data
52
+ - Allows separation of signing and verification roles
53
+
54
+ - **Verification & Extraction**
55
+ - Payloads may be partially or fully extractable even after modification
56
+ - Automatically fails verification when tampering is detected
57
+
58
+ - **Fully Local & Offline**
59
+ - No external servers or network dependencies
60
+ - Pure Python implementation
61
+
62
+ - **Lossless Format Support**
63
+ - Supports PNG and BMP (24-bit) images
64
+ - Lossy formats (e.g., JPEG, WebP) are intentionally excluded to preserve integrity guarantees
65
+
66
+ ## Installation
67
+
68
+ ```bash
69
+ pip install Pixseal
70
+ # or for local development
71
+ pip install -e ./pip_package
72
+ ```
73
+
74
+ Python 3.8+ is required. Wheels published to PyPI already include the compiled
75
+ Cython extension, so `pip install Pixseal` automatically selects the right build
76
+ for your operating system and CPU.
77
+
78
+ ### Building from a git clone
79
+
80
+ If you cloned the repository (or downloaded the source), run the included build
81
+ script to create a wheel/SDist tailored to your environment:
82
+
83
+ ```bash
84
+ git clone https://github.com/kyj9447/Pixseal.git
85
+ cd Pixseal
86
+ python3 -m pip install -r requirements.txt
87
+ ./build.sh
88
+ # Install the freshly built wheel (optional)
89
+ python3 -m pip install pip_package/dist/Pixseal-*.whl
90
+ ```
91
+
92
+ Even after running `python3 -m pip install -r requirements.txt` you still need a
93
+ working C toolchain (`gcc`) and the Python development headers exposed via
94
+ `python3-config`; these come from your OS packages, not pip.
95
+
96
+ `build.sh` invokes Cython and `gcc` (via `python3-config`) before producing the
97
+ wheel/sdist artifacts. Ensure `cython`, `build`, and a working C toolchain are
98
+ installed. If you skip this step, Pixseal falls back to the pure Python
99
+ implementation, which works but is significantly slower.
100
+
101
+ ## Usage
102
+
103
+ ### Sign an image
104
+
105
+ ```python
106
+ from Pixseal import signImage
107
+
108
+ result = signImage(
109
+ imageInput="assets/original.png", # accepts a file path or raw PNG/BMP bytes
110
+ hiddenString="!Validation:kyj9447@mailmail.com",
111
+ publicKeyPath="assets/RSA/public_key.pem", # omit for plain-text embedding
112
+ )
113
+ result.save("assets/signed_original.png")
114
+ ```
115
+
116
+ - The payload is looped if it runs out before the image ends, so even small files carry the full sentinel/payload/end pattern.
117
+ - When `publicKeyPath` is omitted, the payload remains plain text.
118
+
119
+ ### Validate and (optionally) decrypt
120
+
121
+ ```python
122
+ from Pixseal import validateImage
123
+
124
+ report = validateImage(
125
+ imageInput="assets/signed_original.png", # accepts a file path or raw PNG/BMP bytes
126
+ privKeyPath="assets/RSA/private_key.pem", # omit for plain-text payloads
127
+ )
128
+
129
+ print(report["extractedString1"])
130
+ print(report["validationReport"])
131
+ ```
132
+
133
+ `validateImage` returns:
134
+
135
+ ```python
136
+ {
137
+ "extractedString1": "<payload or encrypted blob>",
138
+ "extractedString2": "<truncated payload or encrypted blob>",
139
+ "validationReport": {
140
+ "arrayLength": 4,
141
+ "lengthCheck": True,
142
+ "startCheck": True,
143
+ "endCheck": True,
144
+ "isDecrypted": True,
145
+ "tailCheckResult": True,
146
+ "verdict": True,
147
+ # decryptSkipMessage when a decrypt request was skipped
148
+ }
149
+ }
150
+ ```
151
+
152
+ ### CLI demo script
153
+
154
+ `python testRun.py` offers an interactive flow:
155
+
156
+ 1. Choose **1** to sign an image. It reads `assets/original.png`, asks for a payload (default `!Validation:kyj9447@mailmail.com`), optionally encrypts with `assets/RSA/public_key.pem`, and writes `assets/signed_<name>.png`.
157
+ 2. Choose **2** to validate. It reads `assets/signed_original.png`, optionally decrypts with `assets/RSA/private_key.pem`, and prints both the extracted string and verdict.
158
+ 3. Choose **3** to benchmark performance. It reads `assets/original.png`, encrypts it with `assets/RSA/public_key.pem`, and writes `assets/signed_original.png`, printing the elapsed signing time. Then it reads `assets/signed_original.png`, performs extraction/decryption/validation, and prints the elapsed validation time along with the total elapsed time.
159
+ 4. Choose **4** to test signing and validation with file-path input option.
160
+ 5. Choose **5** to test signing and validation with byte-stream input option.
161
+ 6. Choose **6** to run the optional line-profiler demo. It benchmarks `signImage` and `validateImage`, printing per-line timings when the script is executed through `kernprof`.
162
+
163
+ Option **6** requires the optional dependency `line_profiler` and must be run via `kernprof -l testRun.py` so that `builtins.profile` is provided. Without `line_profiler` installed the script will continue to work, but the profiling option will display an informative message instead of running.
164
+ ### Key management
165
+
166
+ Generate a test RSA pair (PKCS#8) with OpenSSL:
167
+
168
+ ```bash
169
+ openssl genpkey -algorithm RSA -out assets/RSA/private_key.pem -pkeyopt rsa_keygen_bits:2048
170
+ openssl rsa -pubout -in assets/RSA/private_key.pem -out assets/RSA/public_key.pem
171
+ ```
172
+
173
+ Point `publicKeyPath` / `privKeyPath` to these files.
174
+
175
+ ## API reference
176
+
177
+ | Function | Description |
178
+ | --- | --- |
179
+ | `signImage(imageInput, hiddenString, publicKeyPath=None)` | Loads a PNG/BMP from a filesystem path or raw bytes, injects `hiddenString` plus sentinels, encrypting each chunk when `publicKeyPath` is provided. Returns a `SimpleImage` that you can `save()` or `saveBmp()`. |
180
+ | `validateImage(imageInput, privKeyPath=None)` | Reads the hidden bit stream from a path or raw bytes, splits by newlines, deduplicates, optionally decrypts each chunk (Base64 indicates ciphertext), and returns the payload plus a validation report. |
181
+
182
+
183
+ ## Examples
184
+
185
+ | Original | Signed (`!Validation:kyj9447@mailmail.com`) |
186
+ | --- | --- |
187
+ | <img src="https://raw.githubusercontent.com/kyj9447/Pixseal/main/assets/original.png" width="400px"/> | <img src="https://raw.githubusercontent.com/kyj9447/Pixseal/main/assets/signed_original.png" width="400px"/> |
188
+
189
+ Validation output excerpt:
190
+
191
+ ```
192
+ [Validate] verdict: True
193
+ [Validate] extracted string: !Validation:kyj9447@mailmail.com
194
+ [Validate] decrypted with private key: RSA/private_key.pem
195
+
196
+ Validation Report
197
+
198
+ {'extractedString1': '!Validation:kyj9447@mailmail.com',
199
+ 'extractedString2': 'DMnWAzbd6NFycGAxcPkzzmGjL33WXovG...',
200
+ 'validationReport': {'arrayLength': 4,
201
+ 'endCheck': True,
202
+ 'isDecrypted': True,
203
+ 'lengthCheck': True,
204
+ 'startCheck': True,
205
+ 'tailCheckResult': True,
206
+ 'verdict': True}}
207
+ ```
208
+
209
+ (When encrypted, each line appears as Base64 until decrypted with the RSA private key.)
210
+
211
+ | Corrupted after signing |
212
+ | --- |
213
+ |<img src="https://raw.githubusercontent.com/kyj9447/Pixseal/main/assets/currupted_signed_original.png" width="400px"/>
214
+
215
+ Validation output excerpt:
216
+
217
+ ```
218
+ ...
219
+ string argument should contain only ASCII characters
220
+ string argument should contain only ASCII characters
221
+ string argument should contain only ASCII characters
222
+ [Validate] verdict: False
223
+ [Validate] extracted string: !Validation:kyj9447@mailmail.com
224
+ [Validate] decrypted with private key: RSA/private_key.pem
225
+
226
+ Validation Report
227
+
228
+ {'extractedString1': '!Validation:kyj9447@mailmail.com',
229
+ 'extractedString2': 'hh78IWEsRgfTWMw3Rg02hTnCdErjx0O4...',
230
+ 'validationReport': {'arrayLength': 400,
231
+ 'decryptSkipMessage': 'Skip decrypt: payload was plain '
232
+ 'or corrupted text despite decrypt '
233
+ 'request.',
234
+ 'endCheck': True,
235
+ 'isDecrypted': True,
236
+ 'lengthCheck': False,
237
+ 'startCheck': True,
238
+ 'tailCheckResult': 'Not Required',
239
+ 'verdict': False}}
240
+ ```
241
+ ## Related projects
242
+
243
+ https://github.com/kyj9447/imageSignerCamera
244
+ - Mobile camera that signs images on capture:
245
+ - Server-side validator that decrypts and verifies payloads.
@@ -0,0 +1,10 @@
1
+ Pixseal/__init__.py,sha256=EaJiakSK0uB1edVY_RrgsjBiyjWj_i3Us2YHiu7Nfpk,514
2
+ Pixseal/imageSigner.py,sha256=NKpjs5y2JMYvzc2enRkor7Ag8dwMsEectmx-cOwADyg,6170
3
+ Pixseal/imageValidator.py,sha256=HaX8qKWSNYSnHbMfgElcdvGFQgaFQ0ffUbulEFh1HqQ,6155
4
+ Pixseal/simpleImage.py,sha256=QhrbeIBCx2bs7Ehv1ss9fqX7JrjchjgCaGfljDGGQMg,1269
5
+ Pixseal/simpleImage_ext.cp313-win_amd64.pyd,sha256=VC62UlslRtY8LGvxDCmWhCXcWdfE22vJj9v1WylS4WQ,207872
6
+ Pixseal/simpleImage_py.py,sha256=egN7zQO5H7xTWmPSYi8JBiSJDz6SOvJE-RMcXSPH_jo,11168
7
+ pixseal-0.2.1.post2.dist-info/METADATA,sha256=k0iELf5OW2e5EKAYgE61_nBKaJOCpL2XlCzDKQBStBk,9984
8
+ pixseal-0.2.1.post2.dist-info/WHEEL,sha256=qV0EIPljj1XC_vuSatRWjn02nZIz3N1t8jsZz7HBr2U,101
9
+ pixseal-0.2.1.post2.dist-info/top_level.txt,sha256=q35ICL7vJyo5hOpL4YbL5t_g4297gwUPIuLy82kgL3s,8
10
+ pixseal-0.2.1.post2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: false
4
+ Tag: cp313-cp313-win_amd64
5
+
@@ -0,0 +1 @@
1
+ Pixseal