softcloak 1.0.0__py3-none-any.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.
softcloak/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Replace APK strings and re-sign the package."""
2
+
3
+ from softcloak.rewrite import RewriteResult, rewrite_apk
4
+
5
+ __all__ = ["RewriteResult", "rewrite_apk"]
6
+ __version__ = "1.0.0"
softcloak/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from softcloak.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
softcloak/cli.py ADDED
@@ -0,0 +1,122 @@
1
+ """Command line for SoftCloak."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from softcloak.rewrite import rewrite_apk
9
+
10
+
11
+ def _parser() -> argparse.ArgumentParser:
12
+ parser = argparse.ArgumentParser(
13
+ prog="softcloak",
14
+ description=(
15
+ "Replace substrings in an APK with a same-length obfuscated token, "
16
+ "replace exact strings with text you choose, and re-sign the APK."
17
+ ),
18
+ formatter_class=argparse.RawDescriptionHelpFormatter,
19
+ epilog=(
20
+ "examples:\n"
21
+ " softcloak app.apk -o rebuilt.apk --contains com.example --contains CompanyName\n"
22
+ ' softcloak app.apk -o rebuilt.apk --exact "Old full sentence" "New full sentence"\n'
23
+ ),
24
+ )
25
+ parser.add_argument("apk", help="input APK")
26
+ parser.add_argument("-o", "--output", required=True, help="signed output APK")
27
+ parser.add_argument(
28
+ "--contains",
29
+ action="append",
30
+ default=[],
31
+ metavar="SUBSTRING",
32
+ help="replace every occurrence of SUBSTRING with one generated token of the same length",
33
+ )
34
+ parser.add_argument(
35
+ "--exact",
36
+ nargs=2,
37
+ action="append",
38
+ default=[],
39
+ metavar=("OLD", "NEW"),
40
+ help="replace the exact text OLD with NEW. NEW must be the same length unless --fit is set",
41
+ )
42
+ parser.add_argument("--seed", default="softcloak", help="seed for generated tokens")
43
+ parser.add_argument(
44
+ "--fit",
45
+ choices=("error", "pad", "clip"),
46
+ default="error",
47
+ help="what to do when an exact replacement has a different length (default: error)",
48
+ )
49
+ parser.add_argument(
50
+ "--pad-char", default=" ", help="character used by --fit pad or --fit clip"
51
+ )
52
+ parser.add_argument(
53
+ "--allow-missing",
54
+ action="store_true",
55
+ help="do not fail when a search string is absent",
56
+ )
57
+ parser.add_argument(
58
+ "--dry-run",
59
+ action="store_true",
60
+ help="report replacements without writing an APK",
61
+ )
62
+ parser.add_argument(
63
+ "--keystore",
64
+ help="existing keystore. A temporary key is generated when omitted",
65
+ )
66
+ parser.add_argument("--storepass", help="keystore password")
67
+ parser.add_argument("--keypass", help="key password. Defaults to --storepass")
68
+ parser.add_argument("--alias", default="repack", help="key alias")
69
+ return parser
70
+
71
+
72
+ def _print_result(result) -> None:
73
+ for rule in result.rules:
74
+ utf8_count, utf16_count = result.counts.get(rule.old, (0, 0))
75
+ extras = [form for form in rule.forms if form != rule.old]
76
+ suffix = f" also {extras!r}" if extras else ""
77
+ print(
78
+ f"{rule.kind} {rule.old!r} -> {rule.new!r}{suffix} "
79
+ f"(utf8={utf8_count}, utf16={utf16_count})"
80
+ )
81
+ for warning in result.warnings:
82
+ print(f"warning: {warning}", file=sys.stderr)
83
+ if result.dry_run:
84
+ print("dry run: no APK written")
85
+ else:
86
+ print(f"wrote {result.output}")
87
+ print("signature verified")
88
+
89
+
90
+ def main(argv: list[str] | None = None) -> int:
91
+ args = _parser().parse_args(argv)
92
+ for text in args.contains:
93
+ if len(text) < 4:
94
+ print(
95
+ f"warning: {text!r} is very short and can match unrelated bytes",
96
+ file=sys.stderr,
97
+ )
98
+ try:
99
+ result = rewrite_apk(
100
+ args.apk,
101
+ args.output,
102
+ contains=args.contains,
103
+ exact=[tuple(pair) for pair in args.exact],
104
+ seed=args.seed,
105
+ fit=args.fit,
106
+ pad_char=args.pad_char,
107
+ allow_missing=args.allow_missing,
108
+ dry_run=args.dry_run,
109
+ keystore=args.keystore,
110
+ storepass=args.storepass,
111
+ keypass=args.keypass,
112
+ alias=args.alias,
113
+ )
114
+ except (ValueError, RuntimeError) as exc:
115
+ print(f"error: {exc}", file=sys.stderr)
116
+ return 1
117
+ _print_result(result)
118
+ return 0
119
+
120
+
121
+ if __name__ == "__main__":
122
+ raise SystemExit(main())
softcloak/package.py ADDED
@@ -0,0 +1,209 @@
1
+ """Build an aligned APK and sign it with jarsigner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import struct
8
+ import subprocess
9
+ import tempfile
10
+ import zipfile
11
+ import zlib
12
+ from pathlib import Path
13
+
14
+ SIGNATURE_SUFFIXES = (".SF", ".RSA", ".DSA", ".EC")
15
+
16
+
17
+ def is_signature_entry(name: str) -> bool:
18
+ if not name.upper().startswith("META-INF/"):
19
+ return False
20
+ base = name.rsplit("/", 1)[-1].upper()
21
+ return (
22
+ base == "MANIFEST.MF"
23
+ or base.startswith("SIG-")
24
+ or base.endswith(SIGNATURE_SUFFIXES)
25
+ )
26
+
27
+
28
+ def find_java_tool(name: str) -> str:
29
+ found = shutil.which(name)
30
+ if found:
31
+ return found
32
+ java_home = os.environ.get("JAVA_HOME")
33
+ if java_home:
34
+ suffix = ".exe" if os.name == "nt" else ""
35
+ candidate = Path(java_home) / "bin" / f"{name}{suffix}"
36
+ if candidate.is_file():
37
+ return str(candidate)
38
+ raise RuntimeError(f"Could not find {name}. Install a JDK and put it on PATH.")
39
+
40
+
41
+ def _deflate(data: bytes) -> bytes:
42
+ compressor = zlib.compressobj(9, zlib.DEFLATED, -15)
43
+ return compressor.compress(data) + compressor.flush()
44
+
45
+
46
+ def build_zip(entries: list[tuple[zipfile.ZipInfo, bytes]], dest: Path) -> None:
47
+ blob = bytearray()
48
+ central = bytearray()
49
+ for info, data in entries:
50
+ name_b = info.filename.encode("utf-8")
51
+ method = (
52
+ zipfile.ZIP_STORED
53
+ if info.compress_type == zipfile.ZIP_STORED
54
+ else zipfile.ZIP_DEFLATED
55
+ )
56
+ crc = zlib.crc32(data) & 0xFFFFFFFF
57
+ if method == zipfile.ZIP_STORED:
58
+ payload = data
59
+ pad = (4 - ((len(blob) + 30 + len(name_b)) % 4)) % 4
60
+ extra = b"\x00" * pad
61
+ else:
62
+ payload = _deflate(data)
63
+ extra = b""
64
+ date_time = info.date_time or (2026, 10, 12, 0, 0, 0)
65
+ dos_time = (date_time[3] << 11) | (date_time[4] << 5) | (date_time[5] // 2)
66
+ dos_date = ((date_time[0] - 1980) << 9) | (date_time[1] << 5) | date_time[2]
67
+ local_off = len(blob)
68
+ blob += struct.pack(
69
+ "<IHHHHHIIIHH",
70
+ 0x04034B50,
71
+ 20,
72
+ 0,
73
+ method,
74
+ dos_time,
75
+ dos_date,
76
+ crc,
77
+ len(payload),
78
+ len(data),
79
+ len(name_b),
80
+ len(extra),
81
+ )
82
+ blob += name_b + extra + payload
83
+ if (
84
+ method == zipfile.ZIP_STORED
85
+ and (local_off + 30 + len(name_b) + len(extra)) % 4 != 0
86
+ ):
87
+ raise RuntimeError(f"failed to align {info.filename}")
88
+ central += struct.pack(
89
+ "<IHHHHHHIIIHHHHHII",
90
+ 0x02014B50,
91
+ 20,
92
+ 20,
93
+ 0,
94
+ method,
95
+ dos_time,
96
+ dos_date,
97
+ crc,
98
+ len(payload),
99
+ len(data),
100
+ len(name_b),
101
+ len(extra),
102
+ 0,
103
+ 0,
104
+ 0,
105
+ 0,
106
+ local_off,
107
+ )
108
+ central += name_b + extra
109
+ cd_off = len(blob)
110
+ blob += central
111
+ blob += struct.pack(
112
+ "<IHHHHIIH",
113
+ 0x06054B50,
114
+ 0,
115
+ 0,
116
+ len(entries),
117
+ len(entries),
118
+ len(central),
119
+ cd_off,
120
+ 0,
121
+ )
122
+ dest.parent.mkdir(parents=True, exist_ok=True)
123
+ dest.write_bytes(blob)
124
+
125
+
126
+ def _run(command: list[str]) -> None:
127
+ result = subprocess.run(command, capture_output=True, text=True, check=False)
128
+ if result.returncode != 0:
129
+ detail = (result.stderr or result.stdout or "").strip()
130
+ raise RuntimeError(f"{command[0]} failed: {detail}")
131
+
132
+
133
+ def sign_apk(
134
+ unsigned: Path,
135
+ dest: Path,
136
+ *,
137
+ keystore: Path | None = None,
138
+ storepass: str | None = None,
139
+ keypass: str | None = None,
140
+ alias: str = "repack",
141
+ ) -> None:
142
+ keytool = find_java_tool("keytool")
143
+ jarsigner = find_java_tool("jarsigner")
144
+ with tempfile.TemporaryDirectory() as temp_dir:
145
+ temp = Path(temp_dir)
146
+ owned_keystore = keystore is None
147
+ if owned_keystore:
148
+ keystore = temp / "repack.keystore"
149
+ storepass = "android"
150
+ keypass = "android"
151
+ _run(
152
+ [
153
+ keytool,
154
+ "-genkeypair",
155
+ "-keystore",
156
+ str(keystore),
157
+ "-storepass",
158
+ storepass,
159
+ "-keypass",
160
+ keypass,
161
+ "-alias",
162
+ alias,
163
+ "-keyalg",
164
+ "RSA",
165
+ "-keysize",
166
+ "2048",
167
+ "-validity",
168
+ "10000",
169
+ "-dname",
170
+ "CN=SoftCloak, OU=Local, O=Local, C=US",
171
+ ]
172
+ )
173
+ if storepass is None:
174
+ raise RuntimeError("storepass is required when keystore is provided")
175
+ keypass = keypass or storepass
176
+ signed = temp / "signed.apk"
177
+ _run(
178
+ [
179
+ jarsigner,
180
+ "-sigalg",
181
+ "SHA256withRSA",
182
+ "-digestalg",
183
+ "SHA-256",
184
+ "-keystore",
185
+ str(keystore),
186
+ "-storepass",
187
+ storepass,
188
+ "-keypass",
189
+ keypass,
190
+ "-signedjar",
191
+ str(signed),
192
+ str(unsigned),
193
+ alias,
194
+ ]
195
+ )
196
+ with zipfile.ZipFile(signed) as archive:
197
+ entries = [
198
+ (info, archive.read(info.filename)) for info in archive.infolist()
199
+ ]
200
+ build_zip(entries, dest)
201
+ result = subprocess.run(
202
+ [jarsigner, "-verify", str(dest)],
203
+ capture_output=True,
204
+ text=True,
205
+ check=False,
206
+ )
207
+ if result.returncode != 0:
208
+ detail = (result.stderr or result.stdout or "").strip()
209
+ raise RuntimeError(f"signed APK failed verification: {detail}")
softcloak/rewrite.py ADDED
@@ -0,0 +1,300 @@
1
+ """In-place APK string replacement that keeps binary lengths stable."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import struct
8
+ import tempfile
9
+ import zipfile
10
+ import zlib
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from softcloak.package import build_zip, is_signature_entry, sign_apk
15
+
16
+ SKIP_SUFFIXES = {
17
+ ".png",
18
+ ".jpg",
19
+ ".jpeg",
20
+ ".webp",
21
+ ".gif",
22
+ ".so",
23
+ ".mp3",
24
+ ".ogg",
25
+ ".wav",
26
+ ".ttf",
27
+ ".otf",
28
+ }
29
+
30
+
31
+ @dataclass
32
+ class Rule:
33
+ kind: str
34
+ old: str
35
+ new: str
36
+ forms: tuple[str, ...] = ()
37
+
38
+
39
+ @dataclass
40
+ class RewriteResult:
41
+ output: Path | None
42
+ rules: list[Rule]
43
+ counts: dict[str, tuple[int, int]]
44
+ warnings: list[str] = field(default_factory=list)
45
+ dry_run: bool = False
46
+
47
+
48
+ def _ascii(value: str, label: str) -> None:
49
+ try:
50
+ value.encode("ascii")
51
+ except UnicodeEncodeError as exc:
52
+ raise ValueError(
53
+ f"{label} must be ASCII so the APK layout stays intact: {value!r}"
54
+ ) from exc
55
+
56
+
57
+ def _fit(old: str, new: str, mode: str, pad_char: str) -> str:
58
+ if len(pad_char) != 1 or not pad_char.isascii():
59
+ raise ValueError("pad character must be one ASCII character")
60
+ if len(new) == len(old):
61
+ return new
62
+ if mode == "error":
63
+ raise ValueError(
64
+ f"length mismatch for {old!r}: the existing text is {len(old)} characters "
65
+ f"and the replacement is {len(new)}. Use a string of length {len(old)}, "
66
+ "or pass --fit pad or --fit clip."
67
+ )
68
+ if mode == "pad":
69
+ if len(new) > len(old):
70
+ raise ValueError(
71
+ f"replacement for {old!r} is {len(new)} characters and the existing text "
72
+ f"is {len(old)}. --fit pad only fills a shorter string."
73
+ )
74
+ return new + pad_char * (len(old) - len(new))
75
+ if mode == "clip":
76
+ clipped = new[: len(old)]
77
+ return clipped + pad_char * (len(old) - len(clipped))
78
+ raise ValueError(f"unknown fit mode: {mode}")
79
+
80
+
81
+ def obfuscated_token(text: str, blocked: list[str], seed: str) -> str:
82
+ alphabet = "abcdefghijklmnopqrstuvwxyz"
83
+ blocked_lower = [item.lower() for item in blocked if item]
84
+ counter = 0
85
+ while counter < 1000:
86
+ material = hashlib.sha256(f"{seed}:{counter}:{text}".encode()).digest()
87
+ chars: list[str] = []
88
+ while len(chars) < len(text):
89
+ material = hashlib.sha256(material).digest()
90
+ chars.extend(alphabet[byte % 26] for byte in material)
91
+ token = "".join(chars[: len(text)])
92
+ haystack = token.lower()
93
+ if token != text and all(item not in haystack for item in blocked_lower):
94
+ return token
95
+ counter += 1
96
+ raise RuntimeError(f"could not build an obfuscated replacement for {text!r}")
97
+
98
+
99
+ def _replace_span(data: bytes, old: bytes, new: bytes) -> tuple[bytes, int]:
100
+ if len(old) != len(new):
101
+ raise ValueError("internal replacement lengths differ")
102
+ count = data.count(old)
103
+ if count:
104
+ data = data.replace(old, new)
105
+ return data, count
106
+
107
+
108
+ def apply_rules(
109
+ data: bytes, rules: list[Rule]
110
+ ) -> tuple[bytes, dict[str, tuple[int, int]]]:
111
+ counts = {rule.old: [0, 0] for rule in rules}
112
+ for rule in rules:
113
+ for form in rule.forms:
114
+ data, utf8_count = _replace_span(
115
+ data, form.encode("utf-8"), rule.new.encode("utf-8")
116
+ )
117
+ data, utf16_count = _replace_span(
118
+ data,
119
+ form.encode("utf-16le"),
120
+ rule.new.encode("utf-16le"),
121
+ )
122
+ counts[rule.old][0] += utf8_count
123
+ counts[rule.old][1] += utf16_count
124
+ return data, {key: (value[0], value[1]) for key, value in counts.items()}
125
+
126
+
127
+ def separator_forms(text: str) -> tuple[str, ...]:
128
+ """Android class names use '/' in DEX and '.' everywhere else."""
129
+ return tuple(dict.fromkeys((text, text.replace(".", "/"), text.replace("/", "."))))
130
+
131
+
132
+ def present_in(data: bytes, text: str) -> bool:
133
+ return text.encode("utf-8") in data or text.encode("utf-16le") in data
134
+
135
+
136
+ def rule_present(data: bytes, rule: Rule) -> bool:
137
+ return any(present_in(data, form) for form in rule.forms)
138
+
139
+
140
+ def fix_dex(data: bytes) -> bytes:
141
+ if not data.startswith(b"dex\n"):
142
+ return data
143
+ raw = bytearray(data)
144
+ raw[12:32] = hashlib.sha1(raw[32:]).digest()
145
+ raw[8:12] = struct.pack("<I", zlib.adler32(bytes(raw[12:])) & 0xFFFFFFFF)
146
+ return bytes(raw)
147
+
148
+
149
+ def _skipped(name: str) -> bool:
150
+ lower = name.lower()
151
+ return any(lower.endswith(suffix) for suffix in SKIP_SUFFIXES)
152
+
153
+
154
+ def build_rules(
155
+ contains: list[str] | None,
156
+ exact: list[tuple[str, str]] | None,
157
+ *,
158
+ seed: str,
159
+ fit: str,
160
+ pad_char: str,
161
+ ) -> list[Rule]:
162
+ contains = list(contains or [])
163
+ exact = list(exact or [])
164
+ if not contains and not exact:
165
+ raise ValueError(
166
+ "provide at least one --contains substring or --exact replacement"
167
+ )
168
+ for text in contains:
169
+ if not text:
170
+ raise ValueError("--contains does not accept an empty string")
171
+ _ascii(text, "--contains")
172
+ seen = set()
173
+ for text in contains:
174
+ if text in seen:
175
+ raise ValueError(f"repeated --contains value: {text!r}")
176
+ seen.add(text)
177
+
178
+ exact_rules: list[Rule] = []
179
+ used_exact: set[str] = set()
180
+ for old, new in exact:
181
+ if not old:
182
+ raise ValueError("--exact does not accept an empty search string")
183
+ _ascii(old, "exact search text")
184
+ _ascii(new, "exact replacement")
185
+ if old in used_exact:
186
+ raise ValueError(f"repeated --exact search string: {old!r}")
187
+ used_exact.add(old)
188
+ fitted = _fit(old, new, fit, pad_char)
189
+ for text in contains:
190
+ if text in fitted:
191
+ raise ValueError(
192
+ f"exact replacement {fitted!r} still contains --contains value {text!r}. "
193
+ "A later substring pass would change it."
194
+ )
195
+ exact_rules.append(Rule("exact", old, fitted, (old,)))
196
+
197
+ blocked = (
198
+ contains
199
+ + [rule.old for rule in exact_rules]
200
+ + [rule.new for rule in exact_rules]
201
+ )
202
+ contain_rules = [
203
+ Rule(
204
+ "contains",
205
+ text,
206
+ obfuscated_token(text, blocked, seed),
207
+ separator_forms(text),
208
+ )
209
+ for text in sorted(contains, key=len, reverse=True)
210
+ ]
211
+ exact_rules.sort(key=lambda rule: len(rule.old), reverse=True)
212
+ return exact_rules + contain_rules
213
+
214
+
215
+ def rewrite_apk(
216
+ source: str | Path,
217
+ dest: str | Path,
218
+ contains: list[str] | None = None,
219
+ exact: list[tuple[str, str]] | None = None,
220
+ *,
221
+ seed: str = "softcloak",
222
+ fit: str = "error",
223
+ pad_char: str = " ",
224
+ allow_missing: bool = False,
225
+ dry_run: bool = False,
226
+ keystore: str | Path | None = None,
227
+ storepass: str | None = None,
228
+ keypass: str | None = None,
229
+ alias: str = "repack",
230
+ ) -> RewriteResult:
231
+ source_path = Path(source)
232
+ dest_path = Path(dest)
233
+ if not source_path.is_file():
234
+ raise ValueError(f"APK not found: {source_path}")
235
+ rules = build_rules(contains, exact, seed=seed, fit=fit, pad_char=pad_char)
236
+
237
+ with zipfile.ZipFile(source_path) as archive:
238
+ stored = [(info, archive.read(info.filename)) for info in archive.infolist()]
239
+
240
+ warnings: list[str] = []
241
+ missing: list[str] = []
242
+ searchable = []
243
+ for info, data in stored:
244
+ if is_signature_entry(info.filename) or _skipped(info.filename):
245
+ hits = [rule.old for rule in rules if rule_present(data, rule)]
246
+ if hits:
247
+ reason = (
248
+ "signature file"
249
+ if is_signature_entry(info.filename)
250
+ else "skipped binary file"
251
+ )
252
+ warnings.append(
253
+ f"{info.filename} ({reason}) still contains: {', '.join(hits)}. "
254
+ "Those bytes were left unchanged."
255
+ )
256
+ continue
257
+ searchable.append((info, data))
258
+
259
+ for rule in rules:
260
+ if not any(rule_present(data, rule) for _, data in searchable):
261
+ missing.append(rule.old)
262
+ if missing and not allow_missing:
263
+ joined = ", ".join(repr(item) for item in missing)
264
+ raise ValueError(f"no occurrences found for: {joined}")
265
+
266
+ totals = {rule.old: [0, 0] for rule in rules}
267
+ rewritten: list[tuple[zipfile.ZipInfo, bytes]] = []
268
+ for info, data in searchable:
269
+ updated, counts = apply_rules(data, rules)
270
+ for key, (utf8_count, utf16_count) in counts.items():
271
+ totals[key][0] += utf8_count
272
+ totals[key][1] += utf16_count
273
+ if updated != data and info.filename.endswith(".dex"):
274
+ updated = fix_dex(updated)
275
+ rewritten.append((info, updated))
276
+
277
+ result_counts = {key: (value[0], value[1]) for key, value in totals.items()}
278
+ if dry_run:
279
+ return RewriteResult(None, rules, result_counts, warnings, dry_run=True)
280
+
281
+ same_target = source_path.resolve() == dest_path.resolve()
282
+ output = dest_path
283
+ if same_target:
284
+ output = dest_path.with_name(dest_path.name + ".tmp")
285
+
286
+ with tempfile.TemporaryDirectory() as temp_dir:
287
+ unsigned = Path(temp_dir) / "unsigned.apk"
288
+ build_zip(rewritten, unsigned)
289
+ sign_apk(
290
+ unsigned,
291
+ output,
292
+ keystore=Path(keystore) if keystore else None,
293
+ storepass=storepass,
294
+ keypass=keypass,
295
+ alias=alias,
296
+ )
297
+ if same_target:
298
+ os.replace(output, dest_path)
299
+ output = dest_path
300
+ return RewriteResult(output, rules, result_counts, warnings, dry_run=False)
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.4
2
+ Name: softcloak
3
+ Version: 1.0.0
4
+ Summary: Replace strings in an APK and re-sign it.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Provides-Extra: dev
8
+ Requires-Dist: ruff>=0.16; extra == "dev"
9
+
10
+ # SoftCloak
11
+
12
+ The goal of this repository is to try and make reverse engineering more difficult. Currently only
13
+ focused on APK files but will eventually move on to IPA files, binaries, firmware, and other software.
14
+
15
+ SoftCloak replaces text inside an APK, then signs the rebuilt package. Substring matches are swapped for one generated token of the same length. Exact matches are swapped for text you choose. The APK is signed with a new jar signature.
16
+
17
+ Same-length replacements keep the DEX, manifest, and resource table aligned, so the file can still be installed.
18
+
19
+ ## Install
20
+
21
+ A JDK is required. `keytool` and `jarsigner` must be on `PATH`.
22
+
23
+ From the project directory:
24
+
25
+ ```bash
26
+ pip install -e ".[dev]"
27
+ ```
28
+
29
+ The `dev` extra installs Ruff. The test suite runs `ruff check` and `ruff format --check`.
30
+
31
+ ## Use
32
+
33
+ Generate a token for every occurrence of a substring:
34
+
35
+ ```bash
36
+ softcloak app.apk -o rebuilt.apk --contains com.example --contains CompanyName
37
+ ```
38
+
39
+ Replace one exact string with your own text. The new text has to be the same length:
40
+
41
+ ```bash
42
+ softcloak app.apk -o rebuilt.apk --exact "Old full sentence" "New full sentence"
43
+ ```
44
+
45
+ Both can be combined. Exact replacements run first, then substring tokens. The command prints each mapping and checks the signature before it finishes.
46
+
47
+ ```bash
48
+ softcloak app.apk -o rebuilt.apk --dry-run --contains CompanyName
49
+ softcloak app.apk -o rebuilt.apk --exact "abcdef" "xy" --fit pad
50
+ softcloak app.apk -o rebuilt.apk --contains CompanyName --keystore my.keystore --storepass secret --alias mykey
51
+ ```
52
+
53
+ `--fit pad` fills a shorter exact replacement. `--fit clip` cuts a longer one and pads a shorter one. The default is to stop when the lengths differ.
54
+
55
+ `--contains` replaces that text wherever it sits inside a longer string, in every file, in both UTF-8 and UTF-16. A dotted name also rewrites the DEX form with slashes, so `com.example` changes `Lcom/example/...` as well. Matching is case-sensitive. Pass `AppName` and `appname` separately. Patterns shorter than 4 characters are allowed, but they can hit unrelated bytes.
56
+
57
+ Image, audio, font, and native-library entries are left unchanged. Signature files are removed and replaced by the new signature. Other `META-INF` files are kept and edited.
58
+
59
+ ## What can break the app
60
+
61
+ SoftCloak changes only the text you pass in. A same-length rename of your own package or class name can still install and run, because the manifest and `classes.dex` are updated together.
62
+
63
+ The app can crash or fail a lookup when a replacement has to match something that was not changed:
64
+
65
+ - Framework names such as `android` or `java` appear inside system class names. Replacing them makes those calls invalid. A short warning exists for patterns under 4 characters, and it does not stop the run. `android` is long enough to pass through with no warning.
66
+ - Resource paths, such as `res/layout/...`, must match the file names in the APK. SoftCloak does not rename those files.
67
+ - Native libraries are left unchanged. If code now looks up a new name and the library still has the old one, that call fails.
68
+ - Intent actions, content-provider authorities, and server URLs fail when only one side of the match is replaced.
@@ -0,0 +1,10 @@
1
+ softcloak/__init__.py,sha256=OPDrXBzNeYQT2d1DKZOfrzY1sJURdkzmID-CZ6YNB04,175
2
+ softcloak/__main__.py,sha256=JjqiIjpJ-SMRgTRv-rgDn41HL-f5HqsxV8rhIBSpVhw,88
3
+ softcloak/cli.py,sha256=MkhtVxK9ybQsmVH2ypdYYWzua4TldIjgvfhlupCHxxk,4069
4
+ softcloak/package.py,sha256=I9Wa_zBqYz6aJ7ixDLKFh1VRp9-dS8-bFcX9wGqYAhs,6045
5
+ softcloak/rewrite.py,sha256=LWMR4RxU-mv_1BUCCjo_vVfx7FfDYumCK3jcHCsyGOY,9746
6
+ softcloak-1.0.0.dist-info/METADATA,sha256=Qk3jSX3bDz5eJ5PqoTOv7inh_-g4xG0ddEGDsvIJg_w,3420
7
+ softcloak-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ softcloak-1.0.0.dist-info/entry_points.txt,sha256=upeHV2zJs-qsx5HGPtvmy2FULOJg576nT66YYAunupw,49
9
+ softcloak-1.0.0.dist-info/top_level.txt,sha256=-LcF8tK3UTGu9M-R-zUm7qgOPHfYyN0xuIC_vvhadys,10
10
+ softcloak-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ softcloak = softcloak.cli:main
@@ -0,0 +1 @@
1
+ softcloak