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 +26 -0
- Pixseal/imageSigner.py +191 -0
- Pixseal/imageValidator.py +206 -0
- Pixseal/simpleImage.py +35 -0
- Pixseal/simpleImage_ext.cp313-win_amd64.pyd +0 -0
- Pixseal/simpleImage_py.py +319 -0
- pixseal-0.2.1.post2.dist-info/METADATA +245 -0
- pixseal-0.2.1.post2.dist-info/RECORD +10 -0
- pixseal-0.2.1.post2.dist-info/WHEEL +5 -0
- pixseal-0.2.1.post2.dist-info/top_level.txt +1 -0
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"]
|
|
Binary file
|
|
@@ -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 @@
|
|
|
1
|
+
Pixseal
|