fast_media_lock.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580
  1. #!/usr/bin/env python3
  2. """
  3. Fast partial media locker (cross-platform, Python stdlib only).
  4. Goal:
  5. - Very fast "lock/unlock" by encrypting only the first N MB of each file.
  6. - No separate key file; password only.
  7. - Metadata is embedded in each encrypted file tail, so files can be moved anywhere
  8. and still be unlocked by this script.
  9. Security note:
  10. - This is NOT full-file encryption. It is designed for speed/obfuscation.
  11. """
  12. from __future__ import annotations
  13. import argparse
  14. import concurrent.futures
  15. import getpass
  16. import hashlib
  17. import hmac
  18. import os
  19. import struct
  20. import sys
  21. import threading
  22. import time
  23. import zlib
  24. from pathlib import Path
  25. from typing import Iterable
  26. MAGIC = b"FMLKv1!!"
  27. VERSION = 1
  28. SALT_SIZE = 16
  29. NONCE_SIZE = 16
  30. VERIFIER_SIZE = 16
  31. LOCKED_SUFFIX = ".lockx"
  32. IO_CHUNK_SIZE = 8 * 1024 * 1024 # 8MB,兼顾速度与内存
  33. # magic(8) + version(1) + reserved(3) + chunk_size(8) + original_size(8)
  34. # + salt(16) + nonce(16) + verifier(16) + crc32(4)
  35. TRAILER_STRUCT = struct.Struct(">8sB3sQQ16s16s16sI")
  36. TRAILER_SIZE = TRAILER_STRUCT.size
  37. MEDIA_EXTS = {
  38. ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v", ".ts", ".m2ts",
  39. ".mp3", ".wav", ".flac", ".aac", ".m4a", ".ogg", ".wma",
  40. ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic",
  41. }
  42. class LockerError(Exception):
  43. pass
  44. def _derive_keys(password: str, salt: bytes) -> tuple[bytes, bytes]:
  45. km = hashlib.scrypt(
  46. password.encode("utf-8"),
  47. salt=salt,
  48. n=2**14,
  49. r=8,
  50. p=1,
  51. dklen=64,
  52. )
  53. return km[:32], km[32:]
  54. def _xor_bytes(data: bytes, stream_key: bytes, nonce: bytes, block_offset: int = 0) -> bytes:
  55. """与旧版本完全兼容的密钥流 XOR(从 block_offset 开始)"""
  56. if not data:
  57. return b""
  58. out = bytearray(len(data))
  59. mv_in = memoryview(data)
  60. mv_out = memoryview(out)
  61. data_len = len(data)
  62. for off in range(0, data_len, 32):
  63. block_len = min(32, data_len - off)
  64. counter = block_offset + (off // 32)
  65. ks = hmac.digest(stream_key, nonce + counter.to_bytes(8, "big"), hashlib.sha256)
  66. # 32 字节整数 XOR,比纯字节循环快
  67. chunk = int.from_bytes(mv_in[off:off + block_len], "little") ^ int.from_bytes(ks[:block_len], "little")
  68. mv_out[off:off + block_len] = chunk.to_bytes(block_len, "little")
  69. return bytes(out)
  70. def _build_verifier(check_key: bytes) -> bytes:
  71. return hmac.digest(check_key, b"FMLK-PASSWORD-CHECK", hashlib.sha256)[:VERIFIER_SIZE]
  72. def _build_trailer(chunk_size: int, original_size: int, salt: bytes, nonce: bytes, verifier: bytes) -> bytes:
  73. head = TRAILER_STRUCT.pack(
  74. MAGIC,
  75. VERSION,
  76. b"\x00\x00\x00",
  77. chunk_size,
  78. original_size,
  79. salt,
  80. nonce,
  81. verifier,
  82. 0,
  83. )
  84. crc = zlib.crc32(head[:-4]) & 0xFFFFFFFF
  85. return head[:-4] + struct.pack(">I", crc)
  86. def _parse_trailer(raw: bytes) -> dict | None:
  87. if len(raw) != TRAILER_SIZE:
  88. return None
  89. magic, version, _reserved, chunk_size, original_size, salt, nonce, verifier, crc = TRAILER_STRUCT.unpack(raw)
  90. if magic != MAGIC or version != VERSION:
  91. return None
  92. expect_crc = zlib.crc32(raw[:-4]) & 0xFFFFFFFF
  93. if crc != expect_crc:
  94. return None
  95. return {
  96. "chunk_size": chunk_size,
  97. "original_size": original_size,
  98. "salt": salt,
  99. "nonce": nonce,
  100. "verifier": verifier,
  101. }
  102. def _read_trailer(path: Path) -> dict | None:
  103. size = path.stat().st_size
  104. if size < TRAILER_SIZE:
  105. return None
  106. with path.open("rb") as f:
  107. f.seek(-TRAILER_SIZE, os.SEEK_END)
  108. raw = f.read(TRAILER_SIZE)
  109. return _parse_trailer(raw)
  110. def is_encrypted(path: Path) -> bool:
  111. meta = _read_trailer(path)
  112. if not meta:
  113. return False
  114. size = path.stat().st_size
  115. return meta["original_size"] + TRAILER_SIZE == size
  116. def _iter_files(target: Path, all_files: bool) -> Iterable[Path]:
  117. if target.is_file():
  118. yield target
  119. return
  120. for p in target.rglob("*"):
  121. if not p.is_file():
  122. continue
  123. if not all_files and p.suffix.lower() not in MEDIA_EXTS:
  124. continue
  125. yield p
  126. def _iter_files_by_names(target: Path, names: list[str], all_files: bool) -> Iterable[Path]:
  127. if target.is_file():
  128. selected = {target.resolve()}
  129. else:
  130. selected: set[Path] = set()
  131. raw_names = [n.strip() for n in names if n.strip()]
  132. basename_set = {n for n in raw_names if "/" not in n and "\\" not in n}
  133. for raw in raw_names:
  134. if "/" in raw or "\\" in raw:
  135. rel = Path(raw)
  136. cand = (target / rel).resolve()
  137. if cand.is_file():
  138. selected.add(cand)
  139. for p in _iter_files(target, all_files=all_files):
  140. if p.name in basename_set:
  141. selected.add(p.resolve())
  142. for p in sorted(selected):
  143. if not all_files and p.suffix.lower() not in MEDIA_EXTS:
  144. continue
  145. yield p
  146. def _locked_name(path: Path) -> Path:
  147. if path.name.endswith(LOCKED_SUFFIX):
  148. return path
  149. return path.with_name(path.name + LOCKED_SUFFIX)
  150. def _unlocked_name(path: Path) -> Path:
  151. if path.name.endswith(LOCKED_SUFFIX):
  152. return path.with_name(path.name[: -len(LOCKED_SUFFIX)])
  153. return path
  154. class Progress:
  155. """实时整体进度条(安装风格)"""
  156. def __init__(self, total_bytes: int, total_files: int):
  157. self.total_bytes = max(total_bytes, 1)
  158. self.total_files = max(total_files, 1)
  159. self.processed_bytes = 0
  160. self.completed_files = 0
  161. self.current_file = ""
  162. self._lock = threading.Lock()
  163. self._enabled = sys.stdout.isatty()
  164. self._start = time.monotonic()
  165. self._last_len = 0
  166. self._last_update = 0.0
  167. def update(self, added_bytes: int = 0, file_done: bool = False, current: str = ""):
  168. with self._lock:
  169. self.processed_bytes += added_bytes
  170. if file_done:
  171. self.completed_files += 1
  172. if current:
  173. self.current_file = current
  174. now = time.monotonic()
  175. # 限制刷新频率,避免刷屏
  176. if not self._enabled or (now - self._last_update < 0.08 and not file_done):
  177. return
  178. self._last_update = now
  179. self._render()
  180. def _render(self):
  181. elapsed = max(time.monotonic() - self._start, 0.001)
  182. pct = min(100.0, self.processed_bytes * 100.0 / self.total_bytes)
  183. speed = self.processed_bytes / elapsed / (1024 * 1024) # MB/s
  184. bar_width = 28
  185. filled = int(bar_width * pct / 100)
  186. bar = "█" * filled + "░" * (bar_width - filled)
  187. name = self.current_file
  188. if len(name) > 42:
  189. name = "…" + name[-41:]
  190. msg = (
  191. f"\r[{bar}] {pct:5.1f}% "
  192. f"{self.completed_files}/{self.total_files} files "
  193. f"{speed:5.1f} MB/s "
  194. f"{name}"
  195. )
  196. pad = max(0, self._last_len - len(msg))
  197. sys.stdout.write(msg + " " * pad)
  198. sys.stdout.flush()
  199. self._last_len = len(msg)
  200. def finish(self):
  201. if not self._enabled:
  202. return
  203. with self._lock:
  204. self.processed_bytes = self.total_bytes
  205. self.completed_files = self.total_files
  206. self._render()
  207. sys.stdout.write("\n")
  208. sys.stdout.flush()
  209. def encrypt_file(path: Path, password: str, chunk_size: int, progress: Progress | None = None) -> str:
  210. if not path.exists() or not path.is_file():
  211. return "skip(not_file)"
  212. if is_encrypted(path):
  213. return "skip(already_encrypted)"
  214. original_size = path.stat().st_size
  215. if original_size == 0:
  216. return "skip(empty)"
  217. locked_path = _locked_name(path)
  218. if locked_path != path and locked_path.exists():
  219. return "fail(name_conflict)"
  220. salt = os.urandom(SALT_SIZE)
  221. nonce = os.urandom(NONCE_SIZE)
  222. stream_key, check_key = _derive_keys(password, salt)
  223. verifier = _build_verifier(check_key)
  224. n = min(chunk_size, original_size)
  225. if progress:
  226. progress.update(current=str(path))
  227. try:
  228. with path.open("r+b") as f:
  229. processed = 0
  230. block_offset = 0
  231. while processed < n:
  232. read_len = min(IO_CHUNK_SIZE, n - processed)
  233. plain = f.read(read_len)
  234. if not plain:
  235. break
  236. cipher = _xor_bytes(plain, stream_key, nonce, block_offset=block_offset)
  237. f.seek(processed)
  238. f.write(cipher)
  239. processed += len(plain)
  240. block_offset += (len(plain) + 31) // 32
  241. if progress:
  242. progress.update(added_bytes=len(plain))
  243. trailer = _build_trailer(
  244. chunk_size=chunk_size,
  245. original_size=original_size,
  246. salt=salt,
  247. nonce=nonce,
  248. verifier=verifier,
  249. )
  250. f.seek(0, os.SEEK_END)
  251. f.write(trailer)
  252. if locked_path != path:
  253. path.rename(locked_path)
  254. if progress:
  255. progress.update(file_done=True)
  256. return "ok"
  257. except Exception as e:
  258. return f"fail({type(e).__name__})"
  259. def decrypt_file(path: Path, password: str, progress: Progress | None = None) -> str:
  260. if not path.exists() or not path.is_file():
  261. return "skip(not_file)"
  262. meta = _read_trailer(path)
  263. if not meta:
  264. return "skip(not_encrypted)"
  265. size = path.stat().st_size
  266. if meta["original_size"] + TRAILER_SIZE != size:
  267. return "skip(invalid_layout)"
  268. unlocked_path = _unlocked_name(path)
  269. if unlocked_path != path and unlocked_path.exists():
  270. return "fail(name_conflict)"
  271. stream_key, check_key = _derive_keys(password, meta["salt"])
  272. verifier = _build_verifier(check_key)
  273. if not hmac.compare_digest(verifier, meta["verifier"]):
  274. return "fail(wrong_password)"
  275. n = min(meta["chunk_size"], meta["original_size"])
  276. if progress:
  277. progress.update(current=str(path))
  278. try:
  279. with path.open("r+b") as f:
  280. processed = 0
  281. block_offset = 0
  282. while processed < n:
  283. read_len = min(IO_CHUNK_SIZE, n - processed)
  284. cipher = f.read(read_len)
  285. if not cipher:
  286. break
  287. plain = _xor_bytes(cipher, stream_key, meta["nonce"], block_offset=block_offset)
  288. f.seek(processed)
  289. f.write(plain)
  290. processed += len(cipher)
  291. block_offset += (len(cipher) + 31) // 32
  292. if progress:
  293. progress.update(added_bytes=len(cipher))
  294. f.truncate(meta["original_size"])
  295. if unlocked_path != path:
  296. path.rename(unlocked_path)
  297. if progress:
  298. progress.update(file_done=True)
  299. return "ok"
  300. except Exception as e:
  301. return f"fail({type(e).__name__})"
  302. def _ask_password(confirm: bool) -> str:
  303. pw = getpass.getpass("请输入密码: ")
  304. if not pw:
  305. raise LockerError("密码不能为空")
  306. if confirm:
  307. pw2 = getpass.getpass("请再次输入密码: ")
  308. if pw != pw2:
  309. raise LockerError("两次密码不一致")
  310. return pw
  311. def _resolve_workers(workers: int) -> int:
  312. if workers < 0:
  313. raise LockerError("--workers 不能小于0")
  314. if workers == 0:
  315. cpu = os.cpu_count() or 1
  316. return max(1, min(32, cpu * 2))
  317. return workers
  318. def _collect_files(target: Path, all_files: bool, names: list[str] | None) -> list[Path]:
  319. if names:
  320. return list(_iter_files_by_names(target, names, all_files=all_files))
  321. return list(_iter_files(target, all_files=all_files))
  322. def run_encrypt(
  323. target: Path,
  324. password: str,
  325. chunk_mb: int,
  326. all_files: bool,
  327. workers: int,
  328. names: list[str] | None = None,
  329. ) -> int:
  330. chunk_size = chunk_mb * 1024 * 1024
  331. worker_count = _resolve_workers(workers)
  332. files = _collect_files(target, all_files, names)
  333. if not files:
  334. print("没有找到需要处理的文件")
  335. return 0
  336. # 预计算总工作量(用于进度条)
  337. total_bytes = 0
  338. for p in files:
  339. try:
  340. sz = p.stat().st_size
  341. total_bytes += min(chunk_size, sz)
  342. except OSError:
  343. pass
  344. progress = Progress(total_bytes, len(files))
  345. ok = skipped = failed = 0
  346. results: list[tuple[Path, str]] = []
  347. def task(p: Path) -> tuple[Path, str]:
  348. return p, encrypt_file(p, password, chunk_size, progress)
  349. try:
  350. if worker_count == 1:
  351. for p in files:
  352. results.append(task(p))
  353. else:
  354. with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as ex:
  355. futures = {ex.submit(task, p): p for p in files}
  356. for fut in concurrent.futures.as_completed(futures):
  357. results.append(fut.result())
  358. except KeyboardInterrupt:
  359. print("\n已收到中断信号,等待当前任务结束后退出…", file=sys.stderr)
  360. # 尽量收集已完成的结果
  361. pass
  362. progress.finish()
  363. for p, res in results:
  364. if res == "ok":
  365. ok += 1
  366. elif res.startswith("fail"):
  367. failed += 1
  368. print(f"[FAIL] {p} -> {res}")
  369. else:
  370. skipped += 1
  371. print(f"完成: encrypted={ok}, failed={failed}, skipped={skipped}, chunk={chunk_mb}MB, workers={worker_count}")
  372. return 2 if failed else 0
  373. def run_decrypt(
  374. target: Path,
  375. password: str,
  376. all_files: bool,
  377. workers: int,
  378. names: list[str] | None = None,
  379. ) -> int:
  380. worker_count = _resolve_workers(workers)
  381. files = _collect_files(target, all_files, names)
  382. if not files:
  383. print("没有找到需要处理的文件")
  384. return 0
  385. # 解密时无法提前准确知道每个文件的 chunk,用文件大小近似(足够进度条使用)
  386. total_bytes = 0
  387. for p in files:
  388. try:
  389. total_bytes += p.stat().st_size
  390. except OSError:
  391. pass
  392. # 实际只会处理前面的 chunk,这里给一个合理估计即可
  393. total_bytes = max(total_bytes // 8, 1) # 粗略估计
  394. progress = Progress(total_bytes, len(files))
  395. ok = skipped = failed = 0
  396. results: list[tuple[Path, str]] = []
  397. def task(p: Path) -> tuple[Path, str]:
  398. return p, decrypt_file(p, password, progress)
  399. try:
  400. if worker_count == 1:
  401. for p in files:
  402. results.append(task(p))
  403. else:
  404. with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as ex:
  405. futures = {ex.submit(task, p): p for p in files}
  406. for fut in concurrent.futures.as_completed(futures):
  407. results.append(fut.result())
  408. except KeyboardInterrupt:
  409. print("\n已收到中断信号,等待当前任务结束后退出…", file=sys.stderr)
  410. progress.finish()
  411. for p, res in results:
  412. if res == "ok":
  413. ok += 1
  414. elif res.startswith("fail"):
  415. failed += 1
  416. print(f"[FAIL] {p} -> {res}")
  417. else:
  418. skipped += 1
  419. print(f"完成: decrypted={ok}, failed={failed}, skipped={skipped}, workers={worker_count}")
  420. return 2 if failed else 0
  421. def run_status(target: Path, all_files: bool) -> int:
  422. total = encrypted = 0
  423. for p in _iter_files(target, all_files=all_files):
  424. total += 1
  425. flag = is_encrypted(p)
  426. encrypted += int(flag)
  427. print(f"{'[ENC]' if flag else '[RAW]'} {p}")
  428. print(f"统计: total={total}, encrypted={encrypted}, raw={total - encrypted}")
  429. return 0
  430. def build_parser() -> argparse.ArgumentParser:
  431. parser = argparse.ArgumentParser(description="快速部分加密工具(兼容旧版本)")
  432. sub = parser.add_subparsers(dest="cmd", required=True)
  433. for name in ("lock", "unlock", "status", "lock-name", "unlock-name"):
  434. aliases: list[str] = []
  435. if name == "lock":
  436. aliases = ["encrypt"]
  437. elif name == "unlock":
  438. aliases = ["decrypt"]
  439. elif name == "lock-name":
  440. aliases = ["encrypt-name"]
  441. elif name == "unlock-name":
  442. aliases = ["decrypt-name"]
  443. p = sub.add_parser(name, aliases=aliases)
  444. p.add_argument("target", help="文件或目录")
  445. if name in ("lock-name", "unlock-name"):
  446. p.add_argument("names", nargs="+", help="文件名或相对路径(可一次传多个)")
  447. p.add_argument("--media-only", action="store_true", help="仅处理媒体后缀(默认处理所有文件)")
  448. if name in ("lock", "lock-name"):
  449. p.add_argument("--chunk-mb", type=int, default=1, help="加密前多少MB(默认1)")
  450. if name in ("lock", "unlock", "lock-name", "unlock-name"):
  451. p.add_argument("--password", help="密码(不传则交互输入)")
  452. p.add_argument("--workers", type=int, default=1, help="并发线程数,0=自动(默认)")
  453. return parser
  454. def main() -> int:
  455. parser = build_parser()
  456. args = parser.parse_args()
  457. target = Path(args.target)
  458. if not target.exists():
  459. print(f"目标不存在: {target}", file=sys.stderr)
  460. return 1
  461. try:
  462. all_files = not args.media_only
  463. if args.cmd in ("lock", "encrypt"):
  464. if args.chunk_mb <= 0:
  465. raise LockerError("--chunk-mb 必须大于0")
  466. pw = args.password if args.password else _ask_password(confirm=True)
  467. return run_encrypt(target, pw, args.chunk_mb, all_files=all_files, workers=args.workers)
  468. if args.cmd in ("unlock", "decrypt"):
  469. pw = args.password if args.password else _ask_password(confirm=False)
  470. return run_decrypt(target, pw, all_files=all_files, workers=args.workers)
  471. if args.cmd in ("lock-name", "encrypt-name"):
  472. if args.chunk_mb <= 0:
  473. raise LockerError("--chunk-mb 必须大于0")
  474. pw = args.password if args.password else _ask_password(confirm=True)
  475. return run_encrypt(target, pw, args.chunk_mb, all_files=all_files, workers=args.workers, names=args.names)
  476. if args.cmd in ("unlock-name", "decrypt-name"):
  477. pw = args.password if args.password else _ask_password(confirm=False)
  478. return run_decrypt(target, pw, all_files=all_files, workers=args.workers, names=args.names)
  479. return run_status(target, all_files=all_files)
  480. except LockerError as e:
  481. print(f"错误: {e}", file=sys.stderr)
  482. return 1
  483. except KeyboardInterrupt:
  484. print("\n已取消", file=sys.stderr)
  485. return 130
  486. if __name__ == "__main__":
  487. raise SystemExit(main())