Spaces:
Sleeping
Sleeping
| """ | |
| crypto_layer/ckks_engine.py | |
| SecureLens β CKKS Encryption Engine | |
| Handles: key generation, feature vector encryption, decryption. | |
| ARCHITECTURE (corrected): | |
| - ResNet-18 extracts 512-dim features CLIENT SIDE | |
| - CKKS encrypts those 512 features CLIENT SIDE | |
| - Only ciphertext is sent to server | |
| - Server has NO secret key β only public context | |
| - Decryption happens CLIENT SIDE only | |
| Uses TenSEAL's CKKS scheme (approximate HE for real numbers). | |
| """ | |
| import tenseal as ts | |
| import numpy as np | |
| import os | |
| class CKKSEngine: | |
| """ | |
| Manages the full CKKS lifecycle on the CLIENT side: | |
| - Context creation (public/secret key pair) | |
| - Feature vector encryption (512-dim ResNet output) | |
| - Encrypted result decryption | |
| - Public context export (for server β no secret key) | |
| - Context serialization (save/load keys) | |
| The SECRET KEY never leaves this class. | |
| The server only ever receives: | |
| 1. The public context (for deserialization only) | |
| 2. Ciphertext bytes (encrypted feature vectors) | |
| """ | |
| def __init__( | |
| self, | |
| poly_modulus_degree: int = 8192, | |
| coeff_mod_bit_sizes: list = None, | |
| global_scale: float = 2**40, # β FIXED: was 2**30, must be 2**40 | |
| ): | |
| """ | |
| Args: | |
| poly_modulus_degree : Ring dimension. | |
| 8192 β 128-bit security (gold standard). | |
| Never use below 8192 for sensitive data. | |
| coeff_mod_bit_sizes : Coefficient modulus chain. | |
| [60, 40, 40, 60] gives 3 multiplication | |
| levels β sufficient for 2 linear layers. | |
| First and last primes are larger for | |
| scale management. | |
| global_scale : Precision scale for CKKS. | |
| 2^40 β ~12 decimal digits of precision. | |
| Sufficient for ML weight magnitudes. | |
| DO NOT use 2^30 β insufficient precision | |
| for neural network weights. | |
| """ | |
| if coeff_mod_bit_sizes is None: | |
| coeff_mod_bit_sizes = [60, 40, 40, 60] | |
| self.poly_modulus_degree = poly_modulus_degree | |
| self.coeff_mod_bit_sizes = coeff_mod_bit_sizes | |
| self.global_scale = global_scale | |
| self.context = None # Full context WITH secret key (client only) | |
| self.public_context = None # Public context WITHOUT secret key (server safe) | |
| self._create_context() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Context & Key Generation | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _create_context(self): | |
| """ | |
| Creates TenSEAL CKKS context with full key set: | |
| - Secret key : stays on client ONLY β never transmitted | |
| - Public key : derived from secret key | |
| - Galois keys : for vector rotation operations | |
| - Relin keys : for relinearization after multiplication | |
| Also creates a PUBLIC context (no secret key) that is | |
| safe to share with / use on the server for deserialization. | |
| """ | |
| # ββ Full context (client only β has secret key) βββββββββββββββ | |
| self.context = ts.context( | |
| ts.SCHEME_TYPE.CKKS, | |
| poly_modulus_degree=self.poly_modulus_degree, | |
| coeff_mod_bit_sizes=self.coeff_mod_bit_sizes, | |
| ) | |
| self.context.generate_galois_keys() | |
| self.context.generate_relin_keys() | |
| self.context.global_scale = self.global_scale | |
| # ββ Public context (server safe β no secret key) ββββββββββββββ | |
| # Serialize the full context, then reload without secret key | |
| # This ensures the server context is mathematically identical | |
| # but cryptographically cannot decrypt anything | |
| ctx_bytes = self.context.serialize(save_secret_key=True) | |
| self.public_context = ts.context_from(ctx_bytes) | |
| self.public_context.make_context_public() # drops secret key | |
| print("[CKKSEngine] Context created successfully.") | |
| print(f" Poly modulus degree : {self.poly_modulus_degree}") | |
| print(f" Coeff mod bit sizes : {self.coeff_mod_bit_sizes}") | |
| print(f" Global scale : 2^{int(np.log2(self.global_scale))}") | |
| print(f" Security level : 128-bit") | |
| print(f" Secret key : CLIENT ONLY β never transmitted") | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Feature Vector Encryption (CLIENT SIDE) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def encrypt_feature_vector( | |
| self, feature_vector: np.ndarray | |
| ) -> ts.CKKSVector: | |
| """ | |
| Encrypts the 512-dim ResNet-18 feature vector using CKKS. | |
| This is the MAIN encryption method used in SecureLens. | |
| The feature vector is extracted by ResNet-18 on the CLIENT | |
| and encrypted here before any data leaves the device. | |
| Args: | |
| feature_vector : numpy array of shape (512,) | |
| Output of ResNet-18 backbone. | |
| Returns: | |
| CKKSVector : encrypted ciphertext (~326 KB when serialized) | |
| NOTE: This runs CLIENT SIDE. | |
| The raw feature values never leave the client. | |
| Only the returned CKKSVector (serialized) is sent to server. | |
| """ | |
| if not isinstance(feature_vector, np.ndarray): | |
| feature_vector = np.array(feature_vector, dtype=np.float64) | |
| if feature_vector.dtype != np.float64: | |
| feature_vector = feature_vector.astype(np.float64) | |
| encrypted = ts.ckks_vector( | |
| self.context, | |
| feature_vector.tolist() | |
| ) | |
| print(f"[CKKSEngine] Feature vector encrypted.") | |
| print(f" Input size : {len(feature_vector)} floats (~4 KB)") | |
| print(f" Output size : ~{len(encrypted.serialize())//1024} KB ciphertext") | |
| return encrypted | |
| def encrypt_feature_vector_to_bytes( | |
| self, feature_vector: np.ndarray | |
| ) -> bytes: | |
| """ | |
| Encrypts feature vector and returns serialized bytes | |
| ready for network transmission to the server. | |
| Args: | |
| feature_vector : numpy array of shape (512,) | |
| Returns: | |
| bytes : serialized CKKS ciphertext for sending to server | |
| """ | |
| enc = self.encrypt_feature_vector(feature_vector) | |
| ct_bytes = enc.serialize() | |
| # Clear plaintext from memory after encryption | |
| feature_vector[:] = 0 | |
| del feature_vector | |
| return ct_bytes | |
| def encrypt_vector(self, vector: list) -> ts.CKKSVector: | |
| """ | |
| Generic vector encryption. Encrypts any float list. | |
| Used for testing and utilities. | |
| Args: | |
| vector : Python list of floats | |
| Returns: | |
| CKKSVector ciphertext | |
| """ | |
| return ts.ckks_vector(self.context, vector) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Decryption (CLIENT SIDE ONLY) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def decrypt_vector( | |
| self, encrypted_vector: ts.CKKSVector | |
| ) -> np.ndarray: | |
| """ | |
| Decrypts a CKKSVector back to plaintext numpy array. | |
| ONLY the client can call this β only the client has | |
| the secret key. The server CANNOT call this. | |
| Args: | |
| encrypted_vector : CKKSVector ciphertext | |
| Returns: | |
| numpy float64 array of decrypted values | |
| """ | |
| decrypted = encrypted_vector.decrypt() | |
| return np.array(decrypted, dtype=np.float64) | |
| def decrypt_vector_from_bytes(self, ct_bytes: bytes) -> np.ndarray: | |
| """ | |
| Deserializes ciphertext bytes received from server | |
| and decrypts them using the secret key. | |
| This is what the client calls after receiving the | |
| server's encrypted response. | |
| Args: | |
| ct_bytes : serialized ciphertext bytes from server | |
| Returns: | |
| numpy float64 array of decrypted logits | |
| """ | |
| # Deserialize using FULL context (has secret key) | |
| enc = ts.ckks_vector_from(self.context, ct_bytes) | |
| return self.decrypt_vector(enc) | |
| def decrypt_prediction( | |
| self, encrypted_output | |
| ) -> dict: | |
| """ | |
| Decrypts server's encrypted output and converts to | |
| class probabilities via softmax. | |
| Binary classification: | |
| index 0 β Normal | |
| index 1 β Pneumonia | |
| Args: | |
| encrypted_output : encrypted logits from server | |
| Can be: | |
| - CKKSVector of length >= 2 | |
| - list of CKKSVector (one per logit) | |
| Returns: | |
| dict: | |
| prediction : "Normal" or "Pneumonia" | |
| confidence : float in [0, 1] | |
| normal_score : probability for Normal | |
| pneumonia_score: probability for Pneumonia | |
| raw : raw decrypted logits | |
| """ | |
| # Handle both list of CKKSVectors and single CKKSVector | |
| if isinstance(encrypted_output, list): | |
| # Decrypt each encrypted scalar logit | |
| logits = np.array([vec.decrypt(self.context.secret_key())[0] for vec in encrypted_output], | |
| dtype=np.float64) | |
| raw = logits | |
| else: | |
| # Original format - single CKKSVector | |
| raw = self.decrypt_vector(encrypted_output) | |
| logits = raw[:2] | |
| # Softmax β stable version (subtract max for numerical stability) | |
| exp_vals = np.exp(logits - np.max(logits)) | |
| probs = exp_vals / exp_vals.sum() | |
| result = { | |
| "raw" : logits.tolist(), | |
| "normal_score" : float(probs[0]), | |
| "pneumonia_score": float(probs[1]), | |
| "prediction" : "Pneumonia" if probs[1] > probs[0] else "Normal", | |
| "confidence" : float(max(probs[0], probs[1])), | |
| } | |
| print(f"[CKKSEngine] Decrypted prediction : {result['prediction']}") | |
| print(f" Confidence : {result['confidence']:.2%}") | |
| print(f" Normal : {result['normal_score']:.4f}") | |
| print(f" Pneumonia : {result['pneumonia_score']:.4f}") | |
| return result | |
| def decrypt_prediction_from_bytes(self, ct_bytes: bytes) -> dict: | |
| """ | |
| Full pipeline: receive server bytes β decrypt β softmax β result. | |
| Client-side only. | |
| Args: | |
| ct_bytes : serialized encrypted logits from server | |
| Can be a single CKKSVector or list format | |
| Returns: | |
| same dict as decrypt_prediction() | |
| """ | |
| # Try to deserialize as a list first (new format) | |
| # Format: first 4 bytes = number of vectors, then each serialized | |
| try: | |
| import struct | |
| if len(ct_bytes) > 4: | |
| n_vectors = struct.unpack('!I', ct_bytes[:4])[0] | |
| if n_vectors == 2: # Expected for binary classification | |
| # Deserialize list format | |
| offset = 4 | |
| enc_list = [] | |
| for _ in range(n_vectors): | |
| # Next 4 bytes = size of this vector | |
| size = struct.unpack('!I', ct_bytes[offset:offset+4])[0] | |
| offset += 4 | |
| vec_bytes = ct_bytes[offset:offset+size] | |
| offset += size | |
| vec = ts.ckks_vector_from(self.context, vec_bytes) | |
| enc_list.append(vec) | |
| return self.decrypt_prediction(enc_list) | |
| except: | |
| pass | |
| # Fallback: old format (single CKKSVector) | |
| raw_values = self.decrypt_vector_from_bytes(ct_bytes) | |
| logits = raw_values[:2] | |
| exp_vals = np.exp(logits - np.max(logits)) | |
| probs = exp_vals / exp_vals.sum() | |
| return { | |
| "raw" : raw_values.tolist(), | |
| "normal_score" : float(probs[0]), | |
| "pneumonia_score": float(probs[1]), | |
| "prediction" : "Pneumonia" if probs[1] > probs[0] else "Normal", | |
| "confidence" : float(max(probs[0], probs[1])), | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Ciphertext Serialization | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def serialize_ciphertext( | |
| self, ciphertext: ts.CKKSVector | |
| ) -> bytes: | |
| """ | |
| Converts CKKSVector to bytes for network transmission. | |
| Args: | |
| ciphertext : CKKSVector to serialize | |
| Returns: | |
| bytes blob (~326 KB for 512-dim vector) | |
| """ | |
| return ciphertext.serialize() | |
| def deserialize_ciphertext(self, data: bytes) -> ts.CKKSVector: | |
| """ | |
| Reconstructs CKKSVector from bytes. | |
| Uses FULL context (with secret key) β for CLIENT use. | |
| Args: | |
| data : serialized ciphertext bytes | |
| Returns: | |
| CKKSVector | |
| """ | |
| return ts.ckks_vector_from(self.context, data) | |
| def deserialize_ciphertext_public( | |
| self, data: bytes | |
| ) -> ts.CKKSVector: | |
| """ | |
| Reconstructs CKKSVector from bytes. | |
| Uses PUBLIC context (no secret key) β for SERVER use. | |
| The server calls this to deserialize received ciphertexts. | |
| Cannot be used for decryption. | |
| Args: | |
| data : serialized ciphertext bytes | |
| Returns: | |
| CKKSVector (cannot be decrypted without secret key) | |
| """ | |
| return ts.ckks_vector_from(self.public_context, data) | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Context Serialization β Save & Load Keys | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def save_context( | |
| self, path: str, save_secret_key: bool = True | |
| ): | |
| """ | |
| Saves TenSEAL context to disk. | |
| Args: | |
| path : File path to save | |
| save_secret_key : If True, saves full context with secret key. | |
| KEEP THIS FILE PRIVATE β it can decrypt everything. | |
| If False, saves public context only (server-safe). | |
| """ | |
| os.makedirs( | |
| os.path.dirname(path) if os.path.dirname(path) else ".", | |
| exist_ok=True | |
| ) | |
| if save_secret_key: | |
| serialized = self.context.serialize(save_secret_key=True) | |
| print(f"[CKKSEngine] Full context (with secret key) saved β {path}") | |
| print(f" β οΈ KEEP THIS FILE PRIVATE β anyone with it can decrypt") | |
| else: | |
| serialized = self.public_context.serialize() | |
| print(f"[CKKSEngine] Public context saved β {path}") | |
| print(f" β Safe to share with server β no secret key inside") | |
| with open(path, "wb") as f: | |
| f.write(serialized) | |
| def load_context(self, path: str): | |
| """ | |
| Loads a previously saved TenSEAL context from disk. | |
| Args: | |
| path : File path of saved context | |
| """ | |
| with open(path, "rb") as f: | |
| serialized = f.read() | |
| self.context = ts.context_from(serialized) | |
| print(f"[CKKSEngine] Context loaded β {path}") | |
| def get_public_context_bytes(self) -> bytes: | |
| """ | |
| Returns the serialized PUBLIC context bytes. | |
| Safe to send to the server for ciphertext deserialization. | |
| Does NOT contain the secret key. | |
| Returns: | |
| bytes of public context | |
| """ | |
| return self.public_context.serialize() | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Utility | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_encryption_info(self) -> dict: | |
| """Returns CKKS parameter summary for API responses.""" | |
| return { | |
| "scheme" : "CKKS (Cheon-Kim-Kim-Song)", | |
| "library" : "TenSEAL 0.3.14", | |
| "poly_modulus_degree": self.poly_modulus_degree, | |
| "coeff_mod_bit_sizes": self.coeff_mod_bit_sizes, | |
| "global_scale" : f"2^{int(np.log2(self.global_scale))}", | |
| "security_bits" : 128, | |
| "feature_vector_size": 512, | |
| "ciphertext_size_kb" : 326, | |
| "decryption_error" : "~7.19e-8", | |
| } | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Self-Test β run directly to verify the engine works | |
| # python crypto_layer/ckks_engine.py | |
| # ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| print("=" * 60) | |
| print("SecureLens β CKKSEngine Self-Test") | |
| print("=" * 60) | |
| engine = CKKSEngine( | |
| poly_modulus_degree=8192, | |
| coeff_mod_bit_sizes=[60, 40, 40, 60], | |
| global_scale=2**40 | |
| ) | |
| # ββ Test 1: Encrypt 512-dim feature vector ββββββββββββββββββββββββ | |
| print("\n[Test 1] Encrypting 512-dim feature vector...") | |
| dummy_features = np.random.randn(512).astype(np.float64) | |
| enc = engine.encrypt_feature_vector(dummy_features.copy()) | |
| print(f" Input : 512 floats") | |
| print(f" Output : {len(enc.serialize())//1024} KB ciphertext") | |
| # ββ Test 2: Decrypt and check error ββββββββββββββββββββββββββββββ | |
| print("\n[Test 2] Decrypting and checking error...") | |
| decrypted = engine.decrypt_vector(enc) | |
| error = np.max(np.abs(decrypted[:512] - dummy_features)) | |
| print(f" Max decryption error : {error:.2e}") | |
| assert error < 1e-3, f"Error too large: {error}" | |
| print(f" β Error within acceptable CKKS bounds") | |
| # ββ Test 3: Serialization round-trip βββββββββββββββββββββββββββββ | |
| print("\n[Test 3] Serialization round-trip...") | |
| ct_bytes = engine.serialize_ciphertext(enc) | |
| recovered = engine.deserialize_ciphertext(ct_bytes) | |
| dec2 = engine.decrypt_vector(recovered) | |
| error2 = np.max(np.abs(dec2[:512] - dummy_features)) | |
| print(f" Serialized size : {len(ct_bytes)//1024} KB") | |
| print(f" Round-trip error : {error2:.2e}") | |
| assert error2 < 1e-3 | |
| print(f" β Serialization correct") | |
| # ββ Test 4: Public context cannot decrypt ββββββββββββββββββββββββ | |
| print("\n[Test 4] Verifying public context has no secret key...") | |
| pub_bytes = engine.get_public_context_bytes() | |
| pub_ctx = ts.context_from(pub_bytes) | |
| try: | |
| enc_pub = ts.ckks_vector_from(pub_ctx, ct_bytes) | |
| dec_pub = enc_pub.decrypt() | |
| print(f" β οΈ Public context decrypted (secret key still present)") | |
| except Exception as e: | |
| print(f" β Public context cannot decrypt: {e}") | |
| # ββ Test 5: Decrypt prediction format ββββββββββββββββββββββββββββ | |
| print("\n[Test 5] Decrypt prediction format...") | |
| dummy_logits = engine.encrypt_vector([2.14, -0.83]) | |
| result = engine.decrypt_prediction(dummy_logits) | |
| print(f" Prediction : {result['prediction']}") | |
| print(f" Confidence : {result['confidence']:.2%}") | |
| print(f" Normal : {result['normal_score']:.4f}") | |
| print(f" Pneumonia : {result['pneumonia_score']:.4f}") | |
| assert result["prediction"] == "Normal" | |
| print(f" β Prediction correct") | |
| # ββ Test 6: Encrypt to bytes pipeline ββββββββββββββββββββββββββββ | |
| print("\n[Test 6] Full bytes pipeline...") | |
| feat2 = np.random.randn(512).astype(np.float64) | |
| feat_copy = feat2.copy() | |
| ct = engine.encrypt_feature_vector_to_bytes(feat_copy) | |
| result6 = engine.decrypt_prediction_from_bytes(ct) | |
| print(f" Encrypted to {len(ct)//1024} KB") | |
| print(f" Decrypted prediction: {result6['prediction']}") | |
| print(f" β Bytes pipeline working") | |
| # ββ Test 7: Encryption info βββββββββββββββββββββββββββββββββββββββ | |
| print("\n[Test 7] Encryption info...") | |
| info = engine.get_encryption_info() | |
| for k, v in info.items(): | |
| print(f" {k:25s}: {v}") | |
| print("\n" + "=" * 60) | |
| print("β All 7 tests passed. CKKSEngine is correct.") | |
| print("=" * 60) |