Create server/app.py
Browse files- server/app.py +235 -0
server/app.py
ADDED
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import io
|
3 |
+
import json
|
4 |
+
import base64
|
5 |
+
import struct
|
6 |
+
import logging
|
7 |
+
|
8 |
+
import gradio as gr
|
9 |
+
from PIL import Image
|
10 |
+
import numpy as np
|
11 |
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
12 |
+
from cryptography.hazmat.primitives import hashes
|
13 |
+
from cryptography.hazmat.primitives import serialization
|
14 |
+
from cryptography.hazmat.primitives.asymmetric import padding
|
15 |
+
from cryptography.exceptions import InvalidTag
|
16 |
+
|
17 |
+
# --- Constants ---
|
18 |
+
HEADER_BITS = 32
|
19 |
+
AES_GCM_NONCE_SIZE = 12
|
20 |
+
|
21 |
+
# --- Configure Logging ---
|
22 |
+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
23 |
+
logger = logging.getLogger(__name__)
|
24 |
+
|
25 |
+
# --- Load Keys and Config ---
|
26 |
+
# For production (e.g., Hugging Face Spaces), the private key should be an environment variable.
|
27 |
+
KEYLOCK_PRIV_KEY = os.environ.get('KEYLOCK_PRIV_KEY')
|
28 |
+
KEYLOCK_STATUS_MESSAGE = ""
|
29 |
+
|
30 |
+
# For local testing, fall back to reading from a file if the environment variable is not set.
|
31 |
+
if not KEYLOCK_PRIV_KEY:
|
32 |
+
try:
|
33 |
+
with open("keylock_priv.pem", "r") as f:
|
34 |
+
KEYLOCK_PRIV_KEY = f.read()
|
35 |
+
logger.warning("Loaded private key from 'keylock_priv.pem'. This is for local testing only. Use environment variables in production.")
|
36 |
+
KEYLOCK_STATUS_MESSAGE = "⚠️ Loaded from `keylock_priv.pem` file. This is acceptable for local testing but insecure for production. Use secrets/environment variables for deployment."
|
37 |
+
except FileNotFoundError:
|
38 |
+
logger.error("FATAL: Private key not found as env var 'KEYLOCK_PRIV_KEY' or in 'keylock_priv.pem'. API is non-functional.")
|
39 |
+
KEYLOCK_STATUS_MESSAGE = "❌ NOT FOUND. The API is non-functional. The administrator must set the `KEYLOCK_PRIV_KEY` secret or provide a `keylock_priv.pem` file."
|
40 |
+
else:
|
41 |
+
logger.info("Successfully loaded private key from environment variable 'KEYLOCK_PRIV_KEY'.")
|
42 |
+
KEYLOCK_STATUS_MESSAGE = "✅ Loaded successfully from secrets/environment variable. This is the recommended secure configuration."
|
43 |
+
|
44 |
+
|
45 |
+
PUBLIC_KEY_PEM_STRING = ""
|
46 |
+
try:
|
47 |
+
with open("keylock_pub.pem", "r") as f:
|
48 |
+
PUBLIC_KEY_PEM_STRING = f.read()
|
49 |
+
logger.info("Successfully loaded public key from keylock_pub.pem for display.")
|
50 |
+
except FileNotFoundError:
|
51 |
+
logger.error("FATAL: keylock_pub.pem not found. The UI will show an error.")
|
52 |
+
PUBLIC_KEY_PEM_STRING = "Error: keylock_pub.pem file not found on the server. Make sure it's in your repository."
|
53 |
+
except Exception as e:
|
54 |
+
logger.error(f"Error loading public key: {e}")
|
55 |
+
PUBLIC_KEY_PEM_STRING = f"Error loading public key: {e}"
|
56 |
+
|
57 |
+
# --- Core Decryption Function ---
|
58 |
+
def decode_data(image_base64_string: str) -> dict:
|
59 |
+
"""
|
60 |
+
Decrypts a payload hidden in a PNG image via LSB steganography and hybrid encryption.
|
61 |
+
"""
|
62 |
+
if not KEYLOCK_PRIV_KEY:
|
63 |
+
error_msg = "Server Error: The API is not configured with a private key. Please contact the administrator."
|
64 |
+
logger.error(error_msg)
|
65 |
+
raise gr.Error(error_msg)
|
66 |
+
|
67 |
+
try:
|
68 |
+
# 1. Decode Base64 and load image data
|
69 |
+
image_buffer = base64.b64decode(image_base64_string)
|
70 |
+
img = Image.open(io.BytesIO(image_buffer)).convert("RGB")
|
71 |
+
pixel_data = np.array(img).ravel()
|
72 |
+
|
73 |
+
# 2. Extract data length from the LSB header
|
74 |
+
if pixel_data.size < HEADER_BITS:
|
75 |
+
raise ValueError(f"Image is too small. Minimum pixel count: {HEADER_BITS}")
|
76 |
+
|
77 |
+
header_binary_string = "".join(str(pixel & 1) for pixel in pixel_data[:HEADER_BITS])
|
78 |
+
data_length = int(header_binary_string, 2)
|
79 |
+
|
80 |
+
if data_length == 0:
|
81 |
+
return {}
|
82 |
+
|
83 |
+
# 3. Extract the full crypto payload
|
84 |
+
data_bits_count = data_length * 8
|
85 |
+
end_offset = HEADER_BITS + data_bits_count
|
86 |
+
if pixel_data.size < end_offset:
|
87 |
+
raise ValueError("Image data corrupt or truncated.")
|
88 |
+
|
89 |
+
data_binary_string = "".join(str(pixel & 1) for pixel in pixel_data[HEADER_BITS:end_offset])
|
90 |
+
crypto_payload = int(data_binary_string, 2).to_bytes(data_length, byteorder='big')
|
91 |
+
|
92 |
+
# 4. Load private key and unpack payload
|
93 |
+
private_key = serialization.load_pem_private_key(KEYLOCK_PRIV_KEY.encode(), password=None)
|
94 |
+
|
95 |
+
offset = 4
|
96 |
+
encrypted_aes_key_len = struct.unpack('>I', crypto_payload[:offset])[0]
|
97 |
+
rsa_key_size_bytes = private_key.key_size // 8
|
98 |
+
if encrypted_aes_key_len != rsa_key_size_bytes:
|
99 |
+
raise ValueError(f"Key mismatch: Encrypted with a {encrypted_aes_key_len*8}-bit key, server uses {private_key.key_size}-bit key.")
|
100 |
+
|
101 |
+
encrypted_aes_key = crypto_payload[offset : offset + encrypted_aes_key_len]
|
102 |
+
offset += encrypted_aes_key_len
|
103 |
+
nonce = crypto_payload[offset : offset + AES_GCM_NONCE_SIZE]
|
104 |
+
offset += AES_GCM_NONCE_SIZE
|
105 |
+
ciphertext_with_tag = crypto_payload[offset:]
|
106 |
+
|
107 |
+
# 5. Decrypt
|
108 |
+
recovered_aes_key = private_key.decrypt(
|
109 |
+
encrypted_aes_key,
|
110 |
+
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
|
111 |
+
)
|
112 |
+
aesgcm = AESGCM(recovered_aes_key)
|
113 |
+
decrypted_bytes = aesgcm.decrypt(nonce, ciphertext_with_tag, None)
|
114 |
+
|
115 |
+
logger.info("Successfully decrypted payload from image.")
|
116 |
+
return json.loads(decrypted_bytes.decode('utf-8'))
|
117 |
+
|
118 |
+
except (ValueError, InvalidTag, TypeError, struct.error) as e:
|
119 |
+
logger.error(f"Decryption failed: {e}")
|
120 |
+
raise gr.Error(f"Decryption failed. The image may be corrupt, not from a compatible client, or used the wrong public key. Details: {e}")
|
121 |
+
except Exception as e:
|
122 |
+
logger.error(f"An unexpected error occurred: {e}", exc_info=True)
|
123 |
+
raise gr.Error(f"An unexpected server error occurred. Details: {e}")
|
124 |
+
|
125 |
+
# ==============================================================================
|
126 |
+
# GRADIO INTERFACE
|
127 |
+
# ==============================================================================
|
128 |
+
with gr.Blocks(title="Secure Decoder API", theme=gr.themes.Soft()) as demo:
|
129 |
+
gr.Markdown("# 🔐 Secure KeyLock Decoder API")
|
130 |
+
gr.Markdown("This application provides a secure API endpoint to decrypt and extract JSON data hidden within PNG images.")
|
131 |
+
|
132 |
+
with gr.Tabs():
|
133 |
+
with gr.TabItem("🚀 Quick Start & Documentation"):
|
134 |
+
# ... (The rest of your extensive Gradio UI markdown goes here, it's perfect as is)
|
135 |
+
gr.Markdown("## What Is This?")
|
136 |
+
gr.Markdown(
|
137 |
+
"This service decodes messages that have been securely hidden inside images. It's designed for use cases where you need to transmit a small, sensitive JSON payload (like authentication tokens, user data, or configuration) "
|
138 |
+
"without it being easily detectable or readable. The process uses a combination of **steganography** (hiding data in the image's pixels) and **hybrid encryption** (the security of RSA combined with the speed of AES)."
|
139 |
+
)
|
140 |
+
|
141 |
+
with gr.Accordion("How It Works (The Gory Details)", open=False):
|
142 |
+
gr.Markdown(
|
143 |
+
"""
|
144 |
+
The security relies on a well-established cryptographic pattern called **Hybrid Encryption**. Here’s the step-by-step process a client must follow to create a compatible image:
|
145 |
+
1. **Client Generates a One-Time Key**: The client creates a random, single-use 32-byte symmetric key for AES-GCM encryption.
|
146 |
+
2. **Client Encrypts Data**: The client takes the JSON payload and encrypts it using the one-time AES key.
|
147 |
+
3. **Client Encrypts the One-Time Key**: The client encrypts the AES key using this server's **Public RSA Key** (provided below).
|
148 |
+
4. **Client Creates the Payload**: The client bundles the encrypted AES key, nonce, and ciphertext into a single binary payload.
|
149 |
+
5. **Client Hides the Payload**: The client uses **Least Significant Bit (LSB) steganography** to hide the payload in a template image.
|
150 |
+
6. **Client Sends Image**: The final image is sent to this API endpoint.
|
151 |
+
7. **Server Decodes**: This server reverses the process: it extracts the payload, uses its private RSA key to decrypt the AES key, and then decrypts the final message.
|
152 |
+
"""
|
153 |
+
)
|
154 |
+
|
155 |
+
gr.Markdown("---")
|
156 |
+
gr.Markdown("## How to Use This API")
|
157 |
+
gr.Markdown("### Step 1: Get the Server's Public Key")
|
158 |
+
gr.Markdown("You must use this public key to encrypt data intended for this server. Save it to a file (e.g., `server_pub.pem`).")
|
159 |
+
gr.Code(value=PUBLIC_KEY_PEM_STRING, language="pem", label="Server Public Key")
|
160 |
+
|
161 |
+
with gr.Accordion("Step 2: Create the Encrypted Image (Client-Side Python Example)", open=True):
|
162 |
+
gr.Markdown("Use the following Python code in your client application to create a KeyLock image.")
|
163 |
+
gr.Code(
|
164 |
+
language="python",
|
165 |
+
label="Client-Side Image Creation Script (create_keylock_image.py)",
|
166 |
+
value="""
|
167 |
+
import json; import os; import struct; from PIL import Image; import numpy as np
|
168 |
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
169 |
+
from cryptography.hazmat.primitives import hashes, serialization
|
170 |
+
from cryptography.hazmat.primitives.asymmetric import padding
|
171 |
+
def create_keylock_image(public_key_pem: str, json_data: dict, template_image_path: str, output_path: str):
|
172 |
+
public_key = serialization.load_pem_public_key(public_key_pem.encode())
|
173 |
+
data_to_encrypt = json.dumps(json_data).encode('utf-8')
|
174 |
+
aes_key = AESGCM.generate_key(bit_length=256)
|
175 |
+
nonce = os.urandom(12)
|
176 |
+
ciphertext_with_tag = AESGCM(aes_key).encrypt(nonce, data_to_encrypt, None)
|
177 |
+
encrypted_aes_key = public_key.encrypt(aes_key, padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None))
|
178 |
+
payload = struct.pack('>I', len(encrypted_aes_key)) + encrypted_aes_key + nonce + ciphertext_with_tag
|
179 |
+
img = Image.open(template_image_path).convert("RGB")
|
180 |
+
pixel_data = np.array(img)
|
181 |
+
full_binary_string = f'{len(payload):032b}' + ''.join(f'{byte:08b}' for byte in payload)
|
182 |
+
if len(full_binary_string) > pixel_data.size:
|
183 |
+
raise ValueError(f"Data is too large. Needs {len(full_binary_string)} pixels, image has {pixel_data.size}.")
|
184 |
+
flat_pixels = pixel_data.ravel()
|
185 |
+
for i in range(len(full_binary_string)):
|
186 |
+
flat_pixels[i] = (flat_pixels[i] & 0b11111110) | int(full_binary_string[i])
|
187 |
+
Image.fromarray(pixel_data).save(output_path, "PNG")
|
188 |
+
print(f"Successfully created encrypted image at {output_path}")
|
189 |
+
# --- Example Usage ---
|
190 |
+
if __name__ == '__main__':
|
191 |
+
SERVER_PUBLIC_KEY = \"\"\"-----BEGIN PUBLIC KEY-----
|
192 |
+
YOUR_SERVER_PUBLIC_KEY_HERE
|
193 |
+
-----END PUBLIC KEY-----\"\"\"
|
194 |
+
my_secret_data = {"user_id": "user-12345", "token": "abcdef-123456"}
|
195 |
+
create_keylock_image(SERVER_PUBLIC_KEY, my_secret_data, "template.png", "encrypted_image.png")
|
196 |
+
"""
|
197 |
+
)
|
198 |
+
|
199 |
+
gr.Markdown("### Step 3: Call the API Endpoint")
|
200 |
+
gr.Markdown(
|
201 |
+
"- **Method**: `POST`\n"
|
202 |
+
"- **Endpoint**: `/run/keylock-auth-decoder`\n"
|
203 |
+
"- **Body**: A JSON object containing a base64-encoded string of the image."
|
204 |
+
)
|
205 |
+
gr.Code(language="json", label="JSON Payload Schema", value='{\n "data": [\n "<base64_encoded_image_string>"\n ]\n}')
|
206 |
+
|
207 |
+
|
208 |
+
with gr.TabItem("⚙️ Server Status & Error Guide"):
|
209 |
+
gr.Markdown("## Server Status")
|
210 |
+
gr.Textbox(label="Private Key Status", value=KEYLOCK_STATUS_MESSAGE, interactive=False, lines=3)
|
211 |
+
|
212 |
+
gr.Markdown("---")
|
213 |
+
gr.Markdown("## Error Handling Guide")
|
214 |
+
gr.Markdown(
|
215 |
+
"""
|
216 |
+
- **`"Decryption failed. ... InvalidTag"`**: The most common error. It means the image was encrypted with a **different public key** or the image file was corrupted.
|
217 |
+
- **`"Decryption failed. ... Key mismatch"`**: The RSA key size used to encrypt the data does not match the server's key size. Use the public key provided by this service.
|
218 |
+
- **`"Image data corrupt or truncated..."`**: The image file is incomplete or damaged.
|
219 |
+
- **`"Server Error: ... not configured"`**: A server-side problem. The administrator has not set the `KEYLOCK_PRIV_KEY` secret correctly.
|
220 |
+
"""
|
221 |
+
)
|
222 |
+
|
223 |
+
# --- Hidden component for the API endpoint ---
|
224 |
+
with gr.Row(visible=False):
|
225 |
+
api_input = gr.Textbox()
|
226 |
+
api_output = gr.JSON()
|
227 |
+
gr.Interface(
|
228 |
+
fn=decode_data,
|
229 |
+
inputs=api_input,
|
230 |
+
outputs=api_output,
|
231 |
+
api_name="keylock-auth-decoder",
|
232 |
+
)
|
233 |
+
|
234 |
+
if __name__ == "__main__":
|
235 |
+
demo.launch()
|