Skip to content

Commit 798b55a

Browse files
committed
feat: Add advanced layout engine and encryption support
- Introduce LayoutEngine for token-stream based obfuscation that preserves formatting, comments, and string styles - Add EncryptionManager for secure obfuscation with key-based encryption - Enhance PythonObfuscator with layout preservation capabilities - Improve CObfuscator symbol detection with better external symbol scanning - Add comprehensive test coverage for new features - Update CLI to support encryption options - Refactor ImportAnalyzer to better handle protected identifiers - Add .obf.c to .gitignore for C obfuscated files
1 parent 1554cdc commit 798b55a

16 files changed

Lines changed: 1353 additions & 277 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ Thumbs.db
6969
# Mistode specific
7070
*.obf.py
7171
*.res.py
72+
*.obf.c
73+
*.res.c
7274
*.map.json
7375
*.key
7476

src/mistode/c.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -132,11 +132,11 @@ def _tokenize(self, text: str) -> Iterator[Match[str]]:
132132
"""Yields regex matches for tokens in the C source code."""
133133
return self.TOKEN_PATTERN.finditer(text)
134134

135-
def _scan_for_undefined_symbols(self, source_code: str) -> Set[str]:
135+
def _scan_for_external_symbols(self, source_code: str) -> Set[str]:
136136
"""
137-
Heuristic scanner to identify symbols that are used but NOT
138-
defined in the file. Acts as a fallback when compiler tools are
139-
unavailable, or as a primary analysis for simple cases.
137+
Heuristic scanner to identify external symbols (used but not defined in file).
138+
Acts as a fallback when compiler tools are unavailable, or as a primary
139+
analysis for simple cases.
140140
"""
141141
defined_symbols = set()
142142
all_identifiers = set()
@@ -304,7 +304,7 @@ def _identify_external_symbols(self, source_code: str) -> Set[str]:
304304
pass
305305

306306
# Method 2: Heuristic Fallback
307-
return self._scan_for_undefined_symbols(source_code)
307+
return self._scan_for_external_symbols(source_code)
308308

309309
def _inject_source_as_comments(self, source_code: str, obfuscated_code: str) -> str:
310310
"""
@@ -434,7 +434,7 @@ def obfuscate(self, source_code: str) -> str:
434434
# 3. Embed Metadata (Keys) - Kept for legacy compatibility
435435
# or if lossless restoration fails
436436
mapping_info = {
437-
"twd": self.mm.mapping,
437+
"identifier_mapping": self.mm.mapping,
438438
"comments": self.mm.comments,
439439
"files": self.mm.file_mapping,
440440
"encryption_key": self.mm.encryption_key,
@@ -481,7 +481,7 @@ def restore(self, source_code: str) -> str:
481481

482482
# Load metadata if not already loaded (or merge)
483483
if not self.mm.mapping:
484-
self.mm.mapping = data.get("twd", {})
484+
self.mm.mapping = data.get("identifier_mapping", {})
485485
self.mm.reverse_mapping = {v: k for k, v in self.mm.mapping.items()}
486486
self.mm.comments = data.get("comments", {})
487487
self.mm.file_mapping = data.get("files", {})

src/mistode/cli.py

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from .c import CObfuscator
2525
from .core import MappingManager, NameGenerator
26+
from .encrypt import EncryptionManager
2627
from .python import PythonObfuscator
2728

2829

@@ -44,8 +45,14 @@ class CLIError(Exception):
4445
pass
4546

4647

47-
class FileNotFoundError(CLIError):
48-
pass
48+
class FileNotFound(CLIError):
49+
"""Custom FileNotFound to avoid conflict with built-in FileNotFoundError"""
50+
51+
def __init__(self, filepath: str = ""):
52+
self.filepath = filepath
53+
super().__init__(
54+
f"File not found: {filepath}" if filepath else "File not found"
55+
)
4956

5057

5158
class ObfuscationError(CLIError):
@@ -58,6 +65,7 @@ class Options:
5865
input_file: Path
5966
output_file: Optional[Path] = None
6067
key_file: Optional[Path] = None
68+
password: Optional[str] = None
6169
seed: Optional[int] = None
6270
style: str = "similar"
6371
length: int = 16
@@ -69,9 +77,17 @@ class ObfuscationService:
6977
def __init__(self, options: Options):
7078
self.options = options
7179
self.mm = MappingManager()
72-
self.gen = NameGenerator(
73-
length=options.length, style=options.style, seed=options.seed
74-
)
80+
# If seed is not provided but password is, derive seed from password
81+
seed = options.seed
82+
if seed is None and options.password:
83+
# Deterministic seed from password for consistent obfuscation
84+
import hashlib
85+
86+
seed = int.from_bytes(
87+
hashlib.sha256(options.password.encode()).digest()[:8], "big"
88+
)
89+
90+
self.gen = NameGenerator(length=options.length, style=options.style, seed=seed)
7591
self.stats_data = {}
7692

7793
def execute(self) -> None:
@@ -90,7 +106,9 @@ def _obfuscate(self) -> None:
90106
if options.language == Language.PYTHON:
91107
obfuscator = PythonObfuscator(self.mm, self.gen, options.input_file.name)
92108
key_path = str(options.key_file) if options.key_file else None
93-
result = obfuscator.obfuscate(content, key_path) # type: ignore
109+
result = obfuscator.obfuscate(
110+
content, key_path, encryption_key=options.password
111+
)
94112
else:
95113
obfuscator = CObfuscator(self.mm, self.gen, options.input_file.name)
96114
result = obfuscator.obfuscate(content)
@@ -128,7 +146,9 @@ def _restore(self) -> None:
128146
self.mm, self.gen, options.input_file.name
129147
)
130148
key_path = str(options.key_file) if options.key_file else None
131-
result = obfuscator.restore(key_path, content) # type: ignore
149+
result = obfuscator.restore(
150+
key_path, content, encryption_key=options.password
151+
)
132152
else:
133153
obfuscator = CObfuscator(self.mm, self.gen, options.input_file.name)
134154
result = obfuscator.restore(content)
@@ -146,25 +166,23 @@ def _restore(self) -> None:
146166
def _read_file(self, path: Path) -> str:
147167
try:
148168
return path.read_text(encoding="utf-8")
149-
except FileNotFoundError:
150-
raise FileNotFoundError(
169+
except OSError: # Catch file not found and other OS errors
170+
raise FileNotFound(
151171
f"❌ Error: Input file not found: {path}\n"
152172
f"💡 Hint: Check if the file path is correct or use an absolute path"
153173
)
154174
except PermissionError:
155-
raise FileNotFoundError(
175+
raise FileNotFound(
156176
f"❌ Error: Permission denied: {path}\n"
157177
f"💡 Hint: Check file permissions or try running with appropriate rights"
158178
)
159179
except UnicodeDecodeError:
160-
raise FileNotFoundError(
180+
raise FileNotFound(
161181
f"❌ Error: File encoding issue: {path}\n"
162182
f"💡 Hint: Ensure the file is a valid text file with UTF-8 encoding"
163183
)
164184
except Exception as e:
165-
raise FileNotFoundError(
166-
f"❌ Error: Failed to read {path}\n" f"💡 Details: {e}"
167-
)
185+
raise FileNotFound(f"❌ Error: Failed to read {path}\n" f"💡 Details: {e}")
168186

169187
def _write_file(self, path: Path, content: str) -> None:
170188
try:
@@ -189,7 +207,7 @@ def _write_file(self, path: Path, content: str) -> None:
189207
def _load_mapping(self, key_file: Path) -> None:
190208
try:
191209
self.mm.load_mapping(key_file)
192-
except FileNotFoundError:
210+
except OSError: # Catch file not found and other OS errors
193211
raise ObfuscationError(
194212
f"❌ Error: Key file not found: {key_file}\n"
195213
f"💡 Hint: Ensure the key file exists or try restoration without --key (using embedded metadata)"
@@ -339,7 +357,10 @@ def _add_obfuscate_command(self, subparsers) -> None:
339357
)
340358
obf.add_argument("input_file", help="Path to input source file")
341359
obf.add_argument("--out", "-o", help="Path to output file")
342-
obf.add_argument("--key", "-k", help="Path to key file")
360+
obf.add_argument("--key", "-k", help="Path to key file (JSON map)")
361+
obf.add_argument(
362+
"--password", "-p", "--pwd", help="Password for encryption/decryption"
363+
)
343364
obf.add_argument("--seed", "-s", type=int, help="Random seed")
344365
obf.add_argument(
345366
"--style",
@@ -368,7 +389,10 @@ def _add_restore_command(self, subparsers) -> None:
368389
)
369390
res.add_argument("input_file", help="Path to obfuscated file")
370391
res.add_argument("--out", "-o", help="Path to output file")
371-
res.add_argument("--key", "-k", help="Path to key file")
392+
res.add_argument("--key", "-k", help="Path to key file (JSON map)")
393+
res.add_argument(
394+
"--password", "-p", "--pwd", help="Password for encryption/decryption"
395+
)
372396
res.add_argument(
373397
"--stats",
374398
action="store_true",
@@ -401,6 +425,11 @@ def _convert_to_options(self, raw) -> Options:
401425
if hasattr(raw, "seed") and raw.seed is not None
402426
else config.get("seed", None)
403427
),
428+
password=(
429+
raw.password
430+
if hasattr(raw, "password")
431+
else config.get("password", None)
432+
),
404433
style=(
405434
raw.style
406435
if hasattr(raw, "style") and raw.style is not None
@@ -494,7 +523,7 @@ def run() -> None:
494523

495524
try:
496525
service.execute()
497-
except FileNotFoundError as e:
526+
except FileNotFound as e:
498527
print(f"{e}")
499528
sys.exit(1)
500529
except ObfuscationError as e:

src/mistode/core.py

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,12 @@
2121
class NameGenerator:
2222
def __init__(self, length: int = 16, style: str = "similar", seed=None):
2323
"""
24-
Encrypted token generator
24+
Obfuscated token name generator.
2525
2626
Args:
27-
length: token length, range [8,32]
28-
length: token length, range [8,32]
29-
style: naming style - "similar" (visually similar characters),
30-
"random" (purely random)
31-
seed: random seed
27+
length: Token length (8-32 characters).
28+
style: Naming style - "similar" (visually similar chars) or "random".
29+
seed: Random seed for reproducible generation.
3230
"""
3331
if length < 8 or length > 32:
3432
raise ValueError(f"Length must be between 8 and 32, current: {length}")
@@ -63,7 +61,7 @@ def __init__(self, length: int = 16, style: str = "similar", seed=None):
6361

6462
def generate(self) -> str:
6563
"""
66-
Generate unique encrypted token
64+
Generate unique obfuscated identifier
6765
"""
6866
max_attempts = 100
6967
for attempt in range(max_attempts):
@@ -93,7 +91,7 @@ def _generate_single(self) -> str:
9391

9492
def _generate_similar(self) -> str:
9593
"""
96-
Generate similar character style token
94+
Generate similar character style identifier with random first character
9795
"""
9896
first_char = self.rng.choice(self.random_letters)
9997

@@ -199,10 +197,16 @@ def get_string_quote_types(self, filename):
199197
def register_file(self, original, obfuscated):
200198
self.file_mapping[obfuscated] = original
201199

202-
def load_mapping(self, filepath):
203-
with open(filepath, "r") as f:
200+
def load_mapping(self, filepath: str) -> None:
201+
"""
202+
Load mapping data from JSON file.
203+
204+
Args:
205+
filepath: Path to the mapping JSON file.
206+
"""
207+
with open(filepath, "r", encoding="utf-8") as f:
204208
data = json.load(f)
205-
self.mapping = data.get("twd", {}) # forward
209+
self.mapping = data.get("identifier_mapping", {})
206210
self.reverse_mapping = {v: k for k, v in self.mapping.items()}
207211
self.comments = data.get("comments", {})
208212
self.file_mapping = data.get("files", {})
@@ -213,7 +217,7 @@ def save_mapping(self, filepath):
213217
with open(filepath, "w") as f:
214218
json.dump(
215219
{
216-
"twd": self.mapping,
220+
"identifier_mapping": self.mapping,
217221
"comments": self.comments,
218222
"files": self.file_mapping,
219223
"encryption_key": self.encryption_key,

src/mistode/encrypt.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""
2+
Encryption Manager for Mistode
3+
Provides secure, key-based encryption for source chunks and metadata.
4+
"""
5+
6+
import base64
7+
import hashlib
8+
from typing import Union
9+
10+
11+
class EncryptionManager:
12+
"""
13+
Manages encryption and decryption of data using a user-provided key.
14+
Uses a CTR-mode like stream cipher based on SHA-256 for high entropy.
15+
"""
16+
17+
def __init__(self, key: str):
18+
"""
19+
Initialize with a string key.
20+
The key is hashed to create a 32-byte master key.
21+
"""
22+
if not key:
23+
raise ValueError("Key cannot be empty")
24+
# SHA-256 to get a fixed-length key from arbitrary string
25+
self.key_hash = hashlib.sha256(key.encode("utf-8")).digest()
26+
27+
def _xor_cipher(self, data: bytes) -> bytes:
28+
"""
29+
Encodes/Decodes data using a stream cipher.
30+
Keystream is generated via SHA-256(Key + BlockCounter).
31+
"""
32+
output = bytearray(len(data))
33+
block_size = 32 # SHA-256 output size
34+
num_blocks = (len(data) + block_size - 1) // block_size
35+
36+
for i in range(num_blocks):
37+
# Generate keystream block: SHA256(Key + Counter)
38+
# This ensures each block has a unique mask derived from the key
39+
counter_bytes = i.to_bytes(8, "big")
40+
keystream_block = hashlib.sha256(self.key_hash + counter_bytes).digest()
41+
42+
start = i * block_size
43+
end = min(start + block_size, len(data))
44+
45+
for j in range(start, end):
46+
# keystream_block index is (j - start)
47+
output[j] = data[j] ^ keystream_block[j - start]
48+
49+
return bytes(output)
50+
51+
def encrypt(self, data: Union[str, bytes]) -> str:
52+
"""
53+
Encrypt data and return base64 string.
54+
"""
55+
if isinstance(data, str):
56+
data = data.encode("utf-8")
57+
58+
encrypted = self._xor_cipher(data)
59+
return base64.b64encode(encrypted).decode("ascii")
60+
61+
def decrypt(self, data: str) -> bytes:
62+
"""
63+
Decrypt base64 string and return bytes.
64+
"""
65+
try:
66+
encrypted = base64.b64decode(data)
67+
except Exception:
68+
raise ValueError("Invalid base64 input")
69+
70+
return self._xor_cipher(encrypted)
71+
72+
def get_seed(self) -> int:
73+
"""
74+
Derive a deterministic integer seed from the key.
75+
Used for seeding the random number generator for variable names.
76+
"""
77+
# Take first 8 bytes of key hash and convert to int
78+
return int.from_bytes(self.key_hash[:8], "big")

0 commit comments

Comments
 (0)