# -*- coding: utf-8 -*- """管理用户数据目录里的 nodes.json(Windows DPAPI 加密存储)。 用法(在 Windows 上运行): python nodes_tool.py encrypt <明文nodes.json> # 加密导入到 %LOCALAPPDATA%\\RRPP python nodes_tool.py decrypt # 打印解密后的内容 python nodes_tool.py path # 显示存储路径 注意:DPAPI 加密后只有「本机 + 本用户」能解密;文件拷到别的机器/账户无效。 """ import ctypes import json import os import sys from pathlib import Path def user_data_dir(): if os.name == "nt" and os.environ.get("LOCALAPPDATA"): return Path(os.environ["LOCALAPPDATA"]) / "RRPP" return Path.home() / "RRPP" class _DATA_BLOB(ctypes.Structure): _fields_ = [("cbData", ctypes.c_uint32), ("pbData", ctypes.POINTER(ctypes.c_char))] def dpapi_protect(data: bytes) -> bytes: crypt32 = ctypes.WinDLL("crypt32") kernel32 = ctypes.WinDLL("kernel32") crypt32.CryptProtectData.argtypes = [ctypes.POINTER(_DATA_BLOB), ctypes.c_wchar_p, ctypes.POINTER(_DATA_BLOB), ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DATA_BLOB)] crypt32.CryptProtectData.restype = ctypes.c_bool kernel32.LocalFree.argtypes = [ctypes.c_void_p] buf = ctypes.create_string_buffer(data, len(data)) blob_in = _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))) blob_out = _DATA_BLOB() if not crypt32.CryptProtectData(ctypes.byref(blob_in), "RRPP nodes.json", None, None, None, 0, ctypes.byref(blob_out)): raise OSError("CryptProtectData 调用失败") try: return ctypes.string_at(blob_out.pbData, blob_out.cbData) finally: kernel32.LocalFree(blob_out.pbData) def dpapi_unprotect(data: bytes) -> bytes: crypt32 = ctypes.WinDLL("crypt32") kernel32 = ctypes.WinDLL("kernel32") crypt32.CryptUnprotectData.argtypes = [ctypes.POINTER(_DATA_BLOB), ctypes.POINTER(ctypes.c_wchar_p), ctypes.POINTER(_DATA_BLOB), ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32, ctypes.POINTER(_DATA_BLOB)] crypt32.CryptUnprotectData.restype = ctypes.c_bool kernel32.LocalFree.argtypes = [ctypes.c_void_p] buf = ctypes.create_string_buffer(data, len(data)) blob_in = _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char))) descr = ctypes.c_wchar_p() blob_out = _DATA_BLOB() if not crypt32.CryptUnprotectData(ctypes.byref(blob_in), ctypes.byref(descr), None, None, None, 0, ctypes.byref(blob_out)): raise OSError("CryptUnprotectData 调用失败") try: return ctypes.string_at(blob_out.pbData, blob_out.cbData) finally: kernel32.LocalFree(blob_out.pbData) if descr.value: kernel32.LocalFree(descr) def cmd_encrypt(src: str): plain = Path(src) if not plain.is_file(): print(f"未找到 {plain}") sys.exit(1) data = json.loads(plain.read_text("utf-8")) if not (isinstance(data, list) and data and all(isinstance(n, dict) for n in data)): print("nodes.json 应为非空节点数组") sys.exit(1) out_dir = user_data_dir() out_dir.mkdir(parents=True, exist_ok=True) out = out_dir / "nodes.json" text = json.dumps(data, ensure_ascii=False, indent=2) + "\n" blob = dpapi_protect(text.encode("utf-8")) if os.name == "nt" else text.encode("utf-8") out.write_bytes(blob) print(f"已{'加密' if os.name == 'nt' else '写入(非Windows不加密)'}: {out}({len(data)} 个节点)") def cmd_decrypt(): f = user_data_dir() / "nodes.json" if not f.is_file(): print(f"未找到 {f}") sys.exit(1) blob = f.read_bytes() if os.name == "nt": try: print(dpapi_unprotect(blob).decode("utf-8"), end="") return except Exception as e: print(f"DPAPI 解密失败: {e}") print(blob.decode("utf-8"), end="") def main(): if len(sys.argv) < 2: print(__doc__) sys.exit(1) cmd = sys.argv[1] if cmd == "encrypt" and len(sys.argv) == 3: cmd_encrypt(sys.argv[2]) elif cmd == "decrypt": cmd_decrypt() elif cmd == "path": print(user_data_dir()) else: print(__doc__) sys.exit(1) if __name__ == "__main__": main()