fast_media_lock.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642
  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 zlib
  23. from pathlib import Path
  24. from typing import Iterable
  25. MAGIC = b"FMLKv1!!"
  26. VERSION = 1
  27. SALT_SIZE = 16
  28. NONCE_SIZE = 16
  29. VERIFIER_SIZE = 16
  30. LOCKED_SUFFIX = ".lockx"
  31. IO_CHUNK_SIZE = 4 * 1024 * 1024
  32. # magic(8) + version(1) + reserved(3) + chunk_size(8) + original_size(8)
  33. # + salt(16) + nonce(16) + verifier(16) + crc32(4)
  34. TRAILER_STRUCT = struct.Struct(">8sB3sQQ16s16s16sI")
  35. TRAILER_SIZE = TRAILER_STRUCT.size
  36. MEDIA_EXTS = {
  37. ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", ".m4v", ".ts", ".m2ts",
  38. ".mp3", ".wav", ".flac", ".aac", ".m4a", ".ogg", ".wma",
  39. ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic",
  40. }
  41. class LockerError(Exception):
  42. pass
  43. def _derive_keys(password: str, salt: bytes) -> tuple[bytes, bytes]:
  44. # scrypt is in stdlib and works on Linux/Windows without extra dependencies.
  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 _keystream(stream_key: bytes, nonce: bytes, length: int) -> bytes:
  55. if length <= 0:
  56. return b""
  57. blocks = (length + 31) // 32
  58. out = bytearray(blocks * 32)
  59. mv = memoryview(out)
  60. off = 0
  61. for counter in range(blocks):
  62. block = hmac.digest(stream_key, nonce + counter.to_bytes(8, "big"), hashlib.sha256)
  63. mv[off : off + 32] = block
  64. off += 32
  65. return bytes(mv[:length])
  66. def _xor_bytes(data: bytes, stream_key: bytes, nonce: bytes, block_offset: int = 0) -> bytes:
  67. if not data:
  68. return b""
  69. out = bytearray(len(data))
  70. mv_in = memoryview(data)
  71. mv_out = memoryview(out)
  72. data_len = len(data)
  73. # 分块 XOR,避免一次性把整段数据转成大整数。
  74. for off in range(0, data_len, 32):
  75. block_len = min(32, data_len - off)
  76. ks = hmac.digest(stream_key, nonce + (block_offset + off // 32).to_bytes(8, "big"), hashlib.sha256)
  77. chunk = int.from_bytes(mv_in[off : off + block_len], "little") ^ int.from_bytes(ks[:block_len], "little")
  78. mv_out[off : off + block_len] = chunk.to_bytes(block_len, "little")
  79. return bytes(out)
  80. def _build_verifier(check_key: bytes) -> bytes:
  81. return hmac.digest(check_key, b"FMLK-PASSWORD-CHECK", hashlib.sha256)[:VERIFIER_SIZE]
  82. def _build_trailer(chunk_size: int, original_size: int, salt: bytes, nonce: bytes, verifier: bytes) -> bytes:
  83. head = TRAILER_STRUCT.pack(
  84. MAGIC,
  85. VERSION,
  86. b"\x00\x00\x00",
  87. chunk_size,
  88. original_size,
  89. salt,
  90. nonce,
  91. verifier,
  92. 0,
  93. )
  94. crc = zlib.crc32(head[:-4]) & 0xFFFFFFFF
  95. return head[:-4] + struct.pack(">I", crc)
  96. def _parse_trailer(raw: bytes) -> dict | None:
  97. if len(raw) != TRAILER_SIZE:
  98. return None
  99. magic, version, _reserved, chunk_size, original_size, salt, nonce, verifier, crc = TRAILER_STRUCT.unpack(raw)
  100. if magic != MAGIC or version != VERSION:
  101. return None
  102. expect_crc = zlib.crc32(raw[:-4]) & 0xFFFFFFFF
  103. if crc != expect_crc:
  104. return None
  105. return {
  106. "chunk_size": chunk_size,
  107. "original_size": original_size,
  108. "salt": salt,
  109. "nonce": nonce,
  110. "verifier": verifier,
  111. }
  112. def _read_trailer(path: Path) -> dict | None:
  113. size = path.stat().st_size
  114. if size < TRAILER_SIZE:
  115. return None
  116. with path.open("rb") as f:
  117. f.seek(-TRAILER_SIZE, os.SEEK_END)
  118. raw = f.read(TRAILER_SIZE)
  119. return _parse_trailer(raw)
  120. def is_encrypted(path: Path) -> bool:
  121. meta = _read_trailer(path)
  122. if not meta:
  123. return False
  124. size = path.stat().st_size
  125. # Basic sanity: encrypted file size should be original + trailer.
  126. return meta["original_size"] + TRAILER_SIZE == size
  127. def _iter_files(target: Path, all_files: bool) -> Iterable[Path]:
  128. if target.is_file():
  129. yield target
  130. return
  131. for p in target.rglob("*"):
  132. if not p.is_file():
  133. continue
  134. if not all_files and p.suffix.lower() not in MEDIA_EXTS:
  135. continue
  136. yield p
  137. def _iter_files_by_names(target: Path, names: list[str], all_files: bool) -> Iterable[Path]:
  138. if target.is_file():
  139. selected = {target.resolve()}
  140. else:
  141. selected: set[Path] = set()
  142. raw_names = [n.strip() for n in names if n.strip()]
  143. basename_set = {n for n in raw_names if "/" not in n and "\\" not in n}
  144. for raw in raw_names:
  145. if "/" in raw or "\\" in raw:
  146. rel = Path(raw)
  147. cand = (target / rel).resolve()
  148. if cand.is_file():
  149. selected.add(cand)
  150. for p in _iter_files(target, all_files=all_files):
  151. if p.name in basename_set:
  152. selected.add(p.resolve())
  153. for p in sorted(selected):
  154. if not all_files and p.suffix.lower() not in MEDIA_EXTS:
  155. continue
  156. yield p
  157. def _locked_name(path: Path) -> Path:
  158. if path.name.endswith(LOCKED_SUFFIX):
  159. return path
  160. return path.with_name(path.name + LOCKED_SUFFIX)
  161. def _unlocked_name(path: Path) -> Path:
  162. if path.name.endswith(LOCKED_SUFFIX):
  163. return path.with_name(path.name[: -len(LOCKED_SUFFIX)])
  164. return path
  165. class _ProgressWriter:
  166. def __init__(self) -> None:
  167. self._enabled = sys.stdout.isatty()
  168. self._lock = threading.Lock()
  169. self._last_len = 0
  170. self._file_mode = False
  171. def update(self, label: str, done: int, total: int, path: Path) -> None:
  172. if not self._enabled:
  173. return
  174. if not self._file_mode:
  175. return
  176. total = max(total, 1)
  177. percent = (done * 100) // total
  178. width = 24
  179. filled = min(width, (done * width) // total)
  180. bar = "#" * filled + "-" * (width - filled)
  181. msg = f"{label} [{bar}] {percent:3d}% {done}/{total} {path}"
  182. with self._lock:
  183. pad = max(0, self._last_len - len(msg))
  184. sys.stdout.write("\r" + msg + (" " * pad))
  185. sys.stdout.flush()
  186. self._last_len = len(msg)
  187. def done(self) -> None:
  188. if not self._enabled:
  189. return
  190. if not self._file_mode:
  191. return
  192. with self._lock:
  193. sys.stdout.write("\n")
  194. sys.stdout.flush()
  195. self._last_len = 0
  196. def finish_count(self) -> None:
  197. if not self._enabled:
  198. return
  199. with self._lock:
  200. sys.stdout.write("\n")
  201. sys.stdout.flush()
  202. self._last_len = 0
  203. def set_file_mode(self, enabled: bool) -> None:
  204. self._file_mode = enabled
  205. def count_update(self, done: int, total: int) -> None:
  206. if not self._enabled:
  207. return
  208. with self._lock:
  209. msg = f"已完成: {done}/{total}"
  210. pad = max(0, self._last_len - len(msg))
  211. sys.stdout.write("\r" + msg + (" " * pad))
  212. sys.stdout.flush()
  213. self._last_len = len(msg)
  214. _progress = _ProgressWriter()
  215. def _iter_files_list(target: Path, all_files: bool) -> list[Path]:
  216. return list(_iter_files(target, all_files=all_files))
  217. def _iter_files_by_names_list(target: Path, names: list[str], all_files: bool) -> list[Path]:
  218. return list(_iter_files_by_names(target, names, all_files=all_files))
  219. def encrypt_file(path: Path, password: str, chunk_size: int) -> str:
  220. if not path.exists() or not path.is_file():
  221. return "skip(not_file)"
  222. if is_encrypted(path):
  223. return "skip(already_encrypted)"
  224. original_size = path.stat().st_size
  225. if original_size == 0:
  226. return "skip(empty)"
  227. locked_path = _locked_name(path)
  228. if locked_path != path and locked_path.exists():
  229. return "fail(name_conflict)"
  230. salt = os.urandom(SALT_SIZE)
  231. nonce = os.urandom(NONCE_SIZE)
  232. stream_key, check_key = _derive_keys(password, salt)
  233. verifier = _build_verifier(check_key)
  234. n = min(chunk_size, original_size)
  235. with path.open("r+b") as f:
  236. processed = 0
  237. block_offset = 0
  238. while processed < n:
  239. read_len = min(IO_CHUNK_SIZE, n - processed)
  240. plain = f.read(read_len)
  241. if not plain:
  242. break
  243. cipher = _xor_bytes(plain, stream_key, nonce, block_offset=block_offset)
  244. f.seek(processed)
  245. f.write(cipher)
  246. processed += len(plain)
  247. block_offset += (len(plain) + 31) // 32
  248. _progress.update("[ENC]", processed, n, path)
  249. trailer = _build_trailer(chunk_size=chunk_size, original_size=original_size, salt=salt, nonce=nonce, verifier=verifier)
  250. f.seek(0, os.SEEK_END)
  251. f.write(trailer)
  252. if locked_path != path:
  253. path.rename(locked_path)
  254. _progress.done()
  255. return "ok"
  256. def decrypt_file(path: Path, password: str) -> str:
  257. if not path.exists() or not path.is_file():
  258. return "skip(not_file)"
  259. meta = _read_trailer(path)
  260. if not meta:
  261. return "skip(not_encrypted)"
  262. size = path.stat().st_size
  263. if meta["original_size"] + TRAILER_SIZE != size:
  264. return "skip(invalid_layout)"
  265. unlocked_path = _unlocked_name(path)
  266. if unlocked_path != path and unlocked_path.exists():
  267. return "fail(name_conflict)"
  268. stream_key, check_key = _derive_keys(password, meta["salt"])
  269. verifier = _build_verifier(check_key)
  270. if not hmac.compare_digest(verifier, meta["verifier"]):
  271. return "fail(wrong_password)"
  272. n = min(meta["chunk_size"], meta["original_size"])
  273. with path.open("r+b") as f:
  274. processed = 0
  275. block_offset = 0
  276. while processed < n:
  277. read_len = min(IO_CHUNK_SIZE, n - processed)
  278. cipher = f.read(read_len)
  279. if not cipher:
  280. break
  281. plain = _xor_bytes(cipher, stream_key, meta["nonce"], block_offset=block_offset)
  282. f.seek(processed)
  283. f.write(plain)
  284. processed += len(cipher)
  285. block_offset += (len(cipher) + 31) // 32
  286. _progress.update("[DEC]", processed, n, path)
  287. f.truncate(meta["original_size"])
  288. if unlocked_path != path:
  289. path.rename(unlocked_path)
  290. _progress.done()
  291. return "ok"
  292. def _encrypt_task(args: tuple[Path, str, int]) -> tuple[Path, str]:
  293. path, password, chunk_size = args
  294. return path, encrypt_file(path, password, chunk_size)
  295. def _decrypt_task(args: tuple[Path, str]) -> tuple[Path, str]:
  296. path, password = args
  297. return path, decrypt_file(path, password)
  298. def _ask_password(confirm: bool) -> str:
  299. pw = getpass.getpass("请输入密码: ")
  300. if not pw:
  301. raise LockerError("密码不能为空")
  302. if confirm:
  303. pw2 = getpass.getpass("请再次输入密码: ")
  304. if pw != pw2:
  305. raise LockerError("两次密码不一致")
  306. return pw
  307. def _resolve_workers(workers: int) -> int:
  308. if workers < 0:
  309. raise LockerError("--workers 不能小于0")
  310. if workers == 0:
  311. cpu = os.cpu_count() or 1
  312. return max(1, min(32, cpu * 2))
  313. return workers
  314. def run_encrypt(target: Path, password: str, chunk_mb: int, all_files: bool, workers: int, file_progress: bool, names: list[str] | None = None) -> int:
  315. chunk_size = chunk_mb * 1024 * 1024
  316. worker_count = _resolve_workers(workers)
  317. ok = 0
  318. skipped = 0
  319. failed = 0
  320. _progress.set_file_mode(file_progress)
  321. files = _iter_files_by_names_list(target, names, all_files=all_files) if names else _iter_files_list(target, all_files=all_files)
  322. total_files = len(files)
  323. if not file_progress:
  324. if worker_count == 1:
  325. completed = 0
  326. results = []
  327. try:
  328. for p in files:
  329. results.append((p, encrypt_file(p, password, chunk_size)))
  330. completed += 1
  331. _progress.count_update(completed, total_files)
  332. except KeyboardInterrupt:
  333. print("\n已收到中断信号,当前文件处理完成后退出", file=sys.stderr)
  334. else:
  335. completed = 0
  336. with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as ex:
  337. future_map = {ex.submit(encrypt_file, p, password, chunk_size): p for p in files}
  338. results = []
  339. try:
  340. for fut in concurrent.futures.as_completed(future_map):
  341. p = future_map[fut]
  342. res = fut.result()
  343. results.append((p, res))
  344. completed += 1
  345. _progress.count_update(completed, total_files)
  346. except KeyboardInterrupt:
  347. print("\n已收到中断信号,等待当前文件处理完成后退出", file=sys.stderr)
  348. for fut, p in future_map.items():
  349. if fut.done():
  350. continue
  351. try:
  352. res = fut.result()
  353. except Exception as e:
  354. res = f"fail({type(e).__name__})"
  355. results.append((p, res))
  356. else:
  357. if worker_count == 1:
  358. results = ((p, encrypt_file(p, password, chunk_size)) for p in files)
  359. else:
  360. with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as ex:
  361. results = ex.map(_encrypt_task, ((p, password, chunk_size) for p in files))
  362. for p, res in results:
  363. if res == "ok":
  364. ok += 1
  365. if not file_progress and not sys.stdout.isatty():
  366. print(f"[OK] encrypted: {p}")
  367. elif res.startswith("fail"):
  368. failed += 1
  369. if not file_progress:
  370. print(f"[FAIL] {p} -> {res}")
  371. else:
  372. skipped += 1
  373. if not file_progress and not sys.stdout.isatty():
  374. print(f"[SKIP] {p} -> {res}")
  375. if not file_progress:
  376. _progress.finish_count()
  377. print(f"完成: encrypted={ok}, failed={failed}, skipped={skipped}, chunk={chunk_mb}MB, workers={worker_count}")
  378. return 2 if failed else 0
  379. def run_decrypt(target: Path, password: str, all_files: bool, workers: int, file_progress: bool, names: list[str] | None = None) -> int:
  380. worker_count = _resolve_workers(workers)
  381. ok = 0
  382. skipped = 0
  383. failed = 0
  384. _progress.set_file_mode(file_progress)
  385. files = _iter_files_by_names_list(target, names, all_files=all_files) if names else _iter_files_list(target, all_files=all_files)
  386. total_files = len(files)
  387. if not file_progress:
  388. if worker_count == 1:
  389. completed = 0
  390. results = []
  391. try:
  392. for p in files:
  393. results.append((p, decrypt_file(p, password)))
  394. completed += 1
  395. _progress.count_update(completed, total_files)
  396. except KeyboardInterrupt:
  397. print("\n已收到中断信号,当前文件处理完成后退出", file=sys.stderr)
  398. else:
  399. completed = 0
  400. with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as ex:
  401. future_map = {ex.submit(decrypt_file, p, password): p for p in files}
  402. results = []
  403. try:
  404. for fut in concurrent.futures.as_completed(future_map):
  405. p = future_map[fut]
  406. res = fut.result()
  407. results.append((p, res))
  408. completed += 1
  409. _progress.count_update(completed, total_files)
  410. except KeyboardInterrupt:
  411. print("\n已收到中断信号,等待当前文件处理完成后退出", file=sys.stderr)
  412. for fut, p in future_map.items():
  413. if fut.done():
  414. continue
  415. try:
  416. res = fut.result()
  417. except Exception as e:
  418. res = f"fail({type(e).__name__})"
  419. results.append((p, res))
  420. else:
  421. if worker_count == 1:
  422. results = ((p, decrypt_file(p, password)) for p in files)
  423. else:
  424. with concurrent.futures.ThreadPoolExecutor(max_workers=worker_count) as ex:
  425. results = ex.map(_decrypt_task, ((p, password) for p in files))
  426. for p, res in results:
  427. if res == "ok":
  428. ok += 1
  429. if not file_progress and not sys.stdout.isatty():
  430. print(f"[OK] decrypted: {p}")
  431. elif res.startswith("fail"):
  432. failed += 1
  433. if not file_progress:
  434. print(f"[FAIL] {p} -> {res}")
  435. else:
  436. skipped += 1
  437. if not file_progress and not sys.stdout.isatty():
  438. print(f"[SKIP] {p} -> {res}")
  439. if not file_progress:
  440. _progress.finish_count()
  441. print(f"完成: decrypted={ok}, failed={failed}, skipped={skipped}, workers={worker_count}")
  442. return 2 if failed else 0
  443. def run_status(target: Path, all_files: bool) -> int:
  444. total = 0
  445. encrypted = 0
  446. for p in _iter_files(target, all_files=all_files):
  447. total += 1
  448. flag = is_encrypted(p)
  449. encrypted += int(flag)
  450. print(f"{'[ENC]' if flag else '[RAW]'} {p}")
  451. print(f"统计: total={total}, encrypted={encrypted}, raw={total - encrypted}")
  452. return 0
  453. def build_parser() -> argparse.ArgumentParser:
  454. parser = argparse.ArgumentParser(
  455. description="快速加密工具"
  456. )
  457. sub = parser.add_subparsers(dest="cmd", required=True)
  458. for name in ("lock", "unlock", "status", "lock-name", "unlock-name"):
  459. aliases: list[str] = []
  460. if name == "lock":
  461. aliases = ["encrypt"]
  462. elif name == "unlock":
  463. aliases = ["decrypt"]
  464. elif name == "lock-name":
  465. aliases = ["encrypt-name"]
  466. elif name == "unlock-name":
  467. aliases = ["decrypt-name"]
  468. p = sub.add_parser(name, aliases=aliases)
  469. p.add_argument("target", help="文件或目录")
  470. if name in ("lock-name", "unlock-name"):
  471. p.add_argument("names", nargs="+", help="文件名或相对路径(可一次传多个)")
  472. p.add_argument("--media-only", action="store_true", help="仅处理媒体后缀(默认处理所有文件)")
  473. p.add_argument("--file-progress", action="store_true", help="显示单文件进度条,默认显示已完成文件数/总文件数")
  474. if name in ("lock", "lock-name"):
  475. p.add_argument("--chunk-mb", type=int, default=1, help="加密前多少MB(默认8)")
  476. if name in ("lock", "unlock", "lock-name", "unlock-name"):
  477. p.add_argument("--password", help="密码(不传则交互输入)")
  478. p.add_argument("--workers", type=int, default=0, help="并发线程数,0=自动(默认)")
  479. return parser
  480. def main() -> int:
  481. parser = build_parser()
  482. args = parser.parse_args()
  483. target = Path(args.target)
  484. if not target.exists():
  485. print(f"目标不存在: {target}", file=sys.stderr)
  486. return 1
  487. try:
  488. all_files = not args.media_only
  489. if args.cmd in ("lock", "encrypt"):
  490. if args.chunk_mb <= 0:
  491. raise LockerError("--chunk-mb 必须大于0")
  492. pw = args.password if args.password else _ask_password(confirm=True)
  493. return run_encrypt(target, pw, args.chunk_mb, all_files=all_files, workers=args.workers, file_progress=args.file_progress)
  494. if args.cmd in ("unlock", "decrypt"):
  495. pw = args.password if args.password else _ask_password(confirm=False)
  496. return run_decrypt(target, pw, all_files=all_files, workers=args.workers, file_progress=args.file_progress)
  497. if args.cmd in ("lock-name", "encrypt-name"):
  498. if args.chunk_mb <= 0:
  499. raise LockerError("--chunk-mb 必须大于0")
  500. pw = args.password if args.password else _ask_password(confirm=True)
  501. return run_encrypt(target, pw, args.chunk_mb, all_files=all_files, workers=args.workers, file_progress=args.file_progress, names=args.names)
  502. if args.cmd in ("unlock-name", "decrypt-name"):
  503. pw = args.password if args.password else _ask_password(confirm=False)
  504. return run_decrypt(target, pw, all_files=all_files, workers=args.workers, file_progress=args.file_progress, names=args.names)
  505. return run_status(target, all_files=all_files)
  506. except LockerError as e:
  507. print(f"错误: {e}", file=sys.stderr)
  508. return 1
  509. except KeyboardInterrupt:
  510. print("已取消", file=sys.stderr)
  511. return 130
  512. if __name__ == "__main__":
  513. raise SystemExit(main())