pyfernet-payload 0.2.4__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.
pyfernet/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """PyFernet: encrypt a source directory into one .enc file and run it from memory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.4"
6
+
7
+ from pyfernet.encryptor import encrypt_directory
8
+ from pyfernet.loader import run_payload
9
+
10
+ __all__ = [
11
+ "__version__",
12
+ "encrypt_directory",
13
+ "run_payload",
14
+ ]
pyfernet/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """python -m pyfernet → CLI。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pyfernet.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
pyfernet/cli.py ADDED
@@ -0,0 +1,158 @@
1
+ """命令行入口:pyfernet encrypt | run"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ from pyfernet import __version__
11
+ from pyfernet.encryptor import main as encrypt_main
12
+ from pyfernet.loader import list_payload, main as run_main
13
+
14
+
15
+ DEFAULT_PASSWORD_ENV = "PYFERNET_PASSWORD"
16
+
17
+
18
+ def _password_from_args(password: str | None, password_env: str | None) -> str | None:
19
+ """解析口令:-p > --password-env > 环境变量 PYFERNET_PASSWORD > None(交互)。"""
20
+ if password is not None:
21
+ return password
22
+ env_name = password_env or DEFAULT_PASSWORD_ENV
23
+ # 显式传了 --password-env,或默认名在环境里有值时,走环境变量
24
+ if password_env is not None or env_name in os.environ:
25
+ value = os.environ.get(env_name)
26
+ if not value:
27
+ raise SystemExit(
28
+ f"环境变量未设置或为空: {env_name}\n"
29
+ f" --password-env 后面是「变量名」不是口令本身。\n"
30
+ f" 正确示例:\n"
31
+ f" export {DEFAULT_PASSWORD_ENV}='你的口令'\n"
32
+ f" nohup pyfernet run train.enc --password-env {DEFAULT_PASSWORD_ENV} > d.log 2>&1 &\n"
33
+ f" 或(会进进程列表,仅临时用):\n"
34
+ f" nohup pyfernet run train.enc -p '你的口令' > d.log 2>&1 &"
35
+ )
36
+ return value
37
+ return None
38
+
39
+
40
+ def _split_script_args(argv: list[str]) -> tuple[list[str], list[str]]:
41
+ """将 `run ... -- script_args` 拆开,避免 REMAINDER 吞掉 -p/--password-env。"""
42
+ if argv and argv[0] == "run" and "--" in argv:
43
+ idx = argv.index("--")
44
+ return argv[:idx], argv[idx + 1 :]
45
+ return argv, []
46
+
47
+
48
+ def build_parser() -> argparse.ArgumentParser:
49
+ parser = argparse.ArgumentParser(
50
+ prog="pyfernet",
51
+ description="将源码目录加密为单个 .enc,并在内存中解密运行(磁盘无明文源码)。",
52
+ )
53
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
54
+ sub = parser.add_subparsers(dest="command", required=True)
55
+
56
+ p_enc = sub.add_parser("encrypt", help="打包并加密源码目录")
57
+ p_enc.add_argument("source_dir", type=Path, help="要加密的源码目录")
58
+ p_enc.add_argument(
59
+ "-o",
60
+ "--output",
61
+ type=Path,
62
+ default=Path("train_payload.enc"),
63
+ help="输出密文路径(默认: train_payload.enc)",
64
+ )
65
+ p_enc.add_argument(
66
+ "-e",
67
+ "--entry",
68
+ default="train.py",
69
+ help="相对源目录的入口脚本(默认: train.py)",
70
+ )
71
+ p_enc.add_argument(
72
+ "-p",
73
+ "--password",
74
+ default=None,
75
+ help="口令(不推荐写在命令行;默认交互输入)。也可用 --password-env",
76
+ )
77
+ p_enc.add_argument(
78
+ "--password-env",
79
+ default=None,
80
+ metavar="VAR",
81
+ help="从环境变量读口令(填变量名,如 PYFERNET_PASSWORD;不是口令本身)",
82
+ )
83
+
84
+ p_run = sub.add_parser(
85
+ "run",
86
+ help="解密密文包并在内存中执行",
87
+ epilog="传给训练脚本的参数写在 -- 之后,例如: pyfernet run a.enc -- --epochs 10",
88
+ )
89
+ p_run.add_argument("payload", type=Path, help="密文包路径(.enc)")
90
+ p_run.add_argument(
91
+ "-p",
92
+ "--password",
93
+ default=None,
94
+ help="口令(不推荐写在命令行;默认交互输入)。也可用 --password-env",
95
+ )
96
+ p_run.add_argument(
97
+ "--password-env",
98
+ default=None,
99
+ metavar="VAR",
100
+ help="从环境变量读口令(填变量名,如 PYFERNET_PASSWORD;不是口令本身)",
101
+ )
102
+
103
+ p_list = sub.add_parser("list", help="列出密文包内文件(需口令)")
104
+ p_list.add_argument("payload", type=Path, help="密文包路径(.enc)")
105
+ p_list.add_argument("-p", "--password", default=None, help="口令(默认交互输入)")
106
+ p_list.add_argument(
107
+ "--password-env",
108
+ default=None,
109
+ metavar="VAR",
110
+ help="从环境变量读口令(填变量名;不是口令本身)",
111
+ )
112
+
113
+ return parser
114
+
115
+
116
+ def main(argv: list[str] | None = None) -> int:
117
+ raw = list(sys.argv[1:] if argv is None else argv)
118
+ cli_argv, script_args = _split_script_args(raw)
119
+
120
+ parser = build_parser()
121
+ args = parser.parse_args(cli_argv)
122
+
123
+ if args.command == "encrypt":
124
+ password = _password_from_args(args.password, args.password_env)
125
+ encrypt_main(
126
+ source_dir=str(args.source_dir),
127
+ output_path=str(args.output),
128
+ entry_point=args.entry,
129
+ password=password,
130
+ )
131
+ return 0
132
+
133
+ if args.command == "run":
134
+ password = _password_from_args(args.password, args.password_env)
135
+ entry_name = Path(args.payload).name
136
+ train_argv = [entry_name, *script_args]
137
+ run_main(payload_path=str(args.payload), password=password, argv=train_argv)
138
+ return 0
139
+
140
+ if args.command == "list":
141
+ password = _password_from_args(args.password, args.password_env)
142
+ if password is None:
143
+ import getpass
144
+
145
+ password = getpass.getpass("解密口令: ")
146
+ entry, files = list_payload(args.payload, password)
147
+ print(f"entry: {entry}")
148
+ print(f"files: {len(files)}")
149
+ for name in files:
150
+ print(name)
151
+ return 0
152
+
153
+ parser.error(f"未知命令: {args.command}")
154
+ return 2
155
+
156
+
157
+ if __name__ == "__main__":
158
+ raise SystemExit(main())
pyfernet/constants.py ADDED
@@ -0,0 +1,31 @@
1
+ """Shared payload format constants."""
2
+
3
+ from __future__ import annotations
4
+
5
+ MAGIC = b"PYFE1\0"
6
+ SALT_LEN = 16
7
+ KDF_ITERATIONS = 390_000
8
+ MANIFEST_NAME = "_pyfernet_manifest.json"
9
+
10
+ SKIP_DIRS = {
11
+ ".git",
12
+ "__pycache__",
13
+ ".venv",
14
+ "venv",
15
+ ".mypy_cache",
16
+ ".pytest_cache",
17
+ "node_modules",
18
+ }
19
+ SKIP_SUFFIXES = {".pyc", ".pyo", ".enc"}
20
+ ALLOW_SUFFIXES = {
21
+ ".py",
22
+ ".yaml",
23
+ ".yml",
24
+ ".json",
25
+ ".toml",
26
+ ".cfg",
27
+ ".ini",
28
+ ".txt",
29
+ ".md",
30
+ }
31
+ ALLOW_NAMES = {"LICENSE", "NOTICE"}
pyfernet/encryptor.py ADDED
@@ -0,0 +1,143 @@
1
+ """将指定目录的训练代码打包并 Fernet 加密为单个 .enc 文件。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import io
7
+ import json
8
+ import os
9
+ import zipfile
10
+ from pathlib import Path
11
+
12
+ from pyfernet.constants import (
13
+ ALLOW_NAMES,
14
+ ALLOW_SUFFIXES,
15
+ KDF_ITERATIONS,
16
+ MAGIC,
17
+ MANIFEST_NAME,
18
+ SALT_LEN,
19
+ SKIP_DIRS,
20
+ SKIP_SUFFIXES,
21
+ )
22
+ from pyfernet.fernet_lite import FernetLite, derive_fernet_key
23
+
24
+
25
+ def should_include(path: Path, root: Path) -> bool:
26
+ rel_parts = path.relative_to(root).parts
27
+ if any(p in SKIP_DIRS for p in rel_parts):
28
+ return False
29
+ if path.suffix.lower() in SKIP_SUFFIXES:
30
+ return False
31
+ return path.suffix.lower() in ALLOW_SUFFIXES or path.name in ALLOW_NAMES
32
+
33
+
34
+ def build_zip_bytes(source_dir: Path, entry_point: str) -> bytes:
35
+ source_dir = source_dir.resolve()
36
+ if not source_dir.is_dir():
37
+ raise FileNotFoundError(f"源目录不存在: {source_dir}")
38
+
39
+ entry = Path(entry_point)
40
+ entry_rel = str(entry).replace("\\", "/")
41
+ entry_abs = (source_dir / entry).resolve()
42
+ try:
43
+ entry_abs.relative_to(source_dir)
44
+ except ValueError as e:
45
+ raise FileNotFoundError(f"入口文件不在源目录内: {entry_point}") from e
46
+ if not entry_abs.is_file():
47
+ raise FileNotFoundError(f"入口文件不存在: {entry_point}")
48
+
49
+ buf = io.BytesIO()
50
+ files: list[str] = []
51
+ with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
52
+ for path in sorted(source_dir.rglob("*")):
53
+ if not path.is_file():
54
+ continue
55
+ if not should_include(path, source_dir):
56
+ continue
57
+ arcname = path.relative_to(source_dir).as_posix()
58
+ zf.write(path, arcname)
59
+ files.append(arcname)
60
+
61
+ if entry_rel not in files:
62
+ raise RuntimeError(f"入口 {entry_rel} 未被打包(后缀可能被过滤)")
63
+
64
+ manifest = {
65
+ "version": 1,
66
+ "entry_point": entry_rel,
67
+ "files": files,
68
+ }
69
+ zf.writestr(MANIFEST_NAME, json.dumps(manifest, ensure_ascii=False, indent=2))
70
+
71
+ return buf.getvalue()
72
+
73
+
74
+ def encrypt_bytes(plain: bytes, password: str) -> bytes:
75
+ salt = os.urandom(SALT_LEN)
76
+ key = derive_fernet_key(password, salt, KDF_ITERATIONS)
77
+ token = FernetLite(key).encrypt(plain)
78
+ return MAGIC + salt + token
79
+
80
+
81
+ def encrypt_directory(
82
+ source_dir: str | Path,
83
+ output_path: str | Path,
84
+ entry_point: str,
85
+ password: str,
86
+ ) -> Path:
87
+ zip_bytes = build_zip_bytes(Path(source_dir), entry_point)
88
+ enc = encrypt_bytes(zip_bytes, password)
89
+ out = Path(output_path)
90
+ out.parent.mkdir(parents=True, exist_ok=True)
91
+ out.write_bytes(enc)
92
+ return out
93
+
94
+
95
+ def prompt_password_confirm() -> str:
96
+ password = getpass.getpass("加密口令: ")
97
+ confirm = getpass.getpass("再输入一次: ")
98
+ if password != confirm:
99
+ raise SystemExit("两次口令不一致")
100
+ if not password:
101
+ raise SystemExit("口令不能为空")
102
+ return password
103
+
104
+
105
+ def main(
106
+ source_dir: str,
107
+ output_path: str,
108
+ entry_point: str,
109
+ password: str | None = None,
110
+ ) -> None:
111
+ if password is None:
112
+ password = prompt_password_confirm()
113
+
114
+ out = encrypt_directory(source_dir, output_path, entry_point, password)
115
+ size = out.stat().st_size
116
+ print(f"已生成: {out.resolve()} ({size} bytes)")
117
+ print(f"入口: {entry_point}")
118
+ print("请妥善保存口令;口令不会写入密文文件。")
119
+
120
+
121
+ def _ide_main() -> None:
122
+ # —— IDE 调试:改变量后直接运行本模块 ——
123
+ SOURCE_DIR = "examples/demo_train"
124
+ OUTPUT_PATH = "dist/train_payload.enc"
125
+ ENTRY_POINT = "train.py"
126
+ PASSWORD = None
127
+
128
+ main(
129
+ source_dir=SOURCE_DIR,
130
+ output_path=OUTPUT_PATH,
131
+ entry_point=ENTRY_POINT,
132
+ password=PASSWORD,
133
+ )
134
+
135
+
136
+ if __name__ == "__main__":
137
+ import sys
138
+
139
+ if len(sys.argv) > 1:
140
+ from pyfernet.cli import main as cli_main
141
+
142
+ raise SystemExit(cli_main(["encrypt", *sys.argv[1:]]))
143
+ _ide_main()
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env python3
2
+ """纯标准库 AES-128-CBC + Fernet 兼容加解密(无第三方依赖)。
3
+
4
+ 口令 → PBKDF2-HMAC-SHA256 → 32 字节密钥 → urlsafe-b64 作为 Fernet key。
5
+ 算法与 cryptography.fernet.Fernet 互通(同一 key / token)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import hmac
13
+ import os
14
+ import struct
15
+ import time
16
+
17
+ # --- AES-128(最小实现,仅 ECB 单块 + CBC 包装)---
18
+
19
+ _SBOX = (
20
+ 0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76,
21
+ 0xCA, 0x82, 0xC9, 0x7D, 0xFA, 0x59, 0x47, 0xF0, 0xAD, 0xD4, 0xA2, 0xAF, 0x9C, 0xA4, 0x72, 0xC0,
22
+ 0xB7, 0xFD, 0x93, 0x26, 0x36, 0x3F, 0xF7, 0xCC, 0x34, 0xA5, 0xE5, 0xF1, 0x71, 0xD8, 0x31, 0x15,
23
+ 0x04, 0xC7, 0x23, 0xC3, 0x18, 0x96, 0x05, 0x9A, 0x07, 0x12, 0x80, 0xE2, 0xEB, 0x27, 0xB2, 0x75,
24
+ 0x09, 0x83, 0x2C, 0x1A, 0x1B, 0x6E, 0x5A, 0xA0, 0x52, 0x3B, 0xD6, 0xB3, 0x29, 0xE3, 0x2F, 0x84,
25
+ 0x53, 0xD1, 0x00, 0xED, 0x20, 0xFC, 0xB1, 0x5B, 0x6A, 0xCB, 0xBE, 0x39, 0x4A, 0x4C, 0x58, 0xCF,
26
+ 0xD0, 0xEF, 0xAA, 0xFB, 0x43, 0x4D, 0x33, 0x85, 0x45, 0xF9, 0x02, 0x7F, 0x50, 0x3C, 0x9F, 0xA8,
27
+ 0x51, 0xA3, 0x40, 0x8F, 0x92, 0x9D, 0x38, 0xF5, 0xBC, 0xB6, 0xDA, 0x21, 0x10, 0xFF, 0xF3, 0xD2,
28
+ 0xCD, 0x0C, 0x13, 0xEC, 0x5F, 0x97, 0x44, 0x17, 0xC4, 0xA7, 0x7E, 0x3D, 0x64, 0x5D, 0x19, 0x73,
29
+ 0x60, 0x81, 0x4F, 0xDC, 0x22, 0x2A, 0x90, 0x88, 0x46, 0xEE, 0xB8, 0x14, 0xDE, 0x5E, 0x0B, 0xDB,
30
+ 0xE0, 0x32, 0x3A, 0x0A, 0x49, 0x06, 0x24, 0x5C, 0xC2, 0xD3, 0xAC, 0x62, 0x91, 0x95, 0xE4, 0x79,
31
+ 0xE7, 0xC8, 0x37, 0x6D, 0x8D, 0xD5, 0x4E, 0xA9, 0x6C, 0x56, 0xF4, 0xEA, 0x65, 0x7A, 0xAE, 0x08,
32
+ 0xBA, 0x78, 0x25, 0x2E, 0x1C, 0xA6, 0xB4, 0xC6, 0xE8, 0xDD, 0x74, 0x1F, 0x4B, 0xBD, 0x8B, 0x8A,
33
+ 0x70, 0x3E, 0xB5, 0x66, 0x48, 0x03, 0xF6, 0x0E, 0x61, 0x35, 0x57, 0xB9, 0x86, 0xC1, 0x1D, 0x9E,
34
+ 0xE1, 0xF8, 0x98, 0x11, 0x69, 0xD9, 0x8E, 0x94, 0x9B, 0x1E, 0x87, 0xE9, 0xCE, 0x55, 0x28, 0xDF,
35
+ 0x8C, 0xA1, 0x89, 0x0D, 0xBF, 0xE6, 0x42, 0x68, 0x41, 0x99, 0x2D, 0x0F, 0xB0, 0x54, 0xBB, 0x16,
36
+ )
37
+ _INV_SBOX = tuple(_SBOX.index(i) for i in range(256))
38
+ _RCON = (0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36)
39
+
40
+
41
+ def _xtime(a: int) -> int:
42
+ return ((a << 1) ^ 0x1B) & 0xFF if a & 0x80 else (a << 1) & 0xFF
43
+
44
+
45
+ def _mul(a: int, b: int) -> int:
46
+ r = 0
47
+ for _ in range(8):
48
+ if b & 1:
49
+ r ^= a
50
+ a = _xtime(a)
51
+ b >>= 1
52
+ return r
53
+
54
+
55
+ def _expand_key(key: bytes) -> list[list[int]]:
56
+ if len(key) != 16:
57
+ raise ValueError("AES-128 key must be 16 bytes")
58
+ w = list(key)
59
+ for i in range(4, 44):
60
+ t = w[(i - 1) * 4 : i * 4]
61
+ if i % 4 == 0:
62
+ t = [_SBOX[t[1]] ^ _RCON[i // 4], _SBOX[t[2]], _SBOX[t[3]], _SBOX[t[0]]]
63
+ w.extend(w[(i - 4) * 4 + j] ^ t[j] for j in range(4))
64
+ return [w[i : i + 16] for i in range(0, 176, 16)]
65
+
66
+
67
+ def _add_round_key(s: list[int], rk: list[int]) -> None:
68
+ for i in range(16):
69
+ s[i] ^= rk[i]
70
+
71
+
72
+ def _sub_bytes(s: list[int], box: tuple[int, ...]) -> None:
73
+ for i in range(16):
74
+ s[i] = box[s[i]]
75
+
76
+
77
+ def _shift_rows(s: list[int]) -> None:
78
+ s[1], s[5], s[9], s[13] = s[5], s[9], s[13], s[1]
79
+ s[2], s[6], s[10], s[14] = s[10], s[14], s[2], s[6]
80
+ s[3], s[7], s[11], s[15] = s[15], s[3], s[7], s[11]
81
+
82
+
83
+ def _inv_shift_rows(s: list[int]) -> None:
84
+ s[1], s[5], s[9], s[13] = s[13], s[1], s[5], s[9]
85
+ s[2], s[6], s[10], s[14] = s[10], s[14], s[2], s[6]
86
+ s[3], s[7], s[11], s[15] = s[7], s[11], s[15], s[3]
87
+
88
+
89
+ def _mix_columns(s: list[int]) -> None:
90
+ for c in range(4):
91
+ i = c * 4
92
+ a, b, d, e = s[i], s[i + 1], s[i + 2], s[i + 3]
93
+ s[i] = _mul(a, 2) ^ _mul(b, 3) ^ d ^ e
94
+ s[i + 1] = a ^ _mul(b, 2) ^ _mul(d, 3) ^ e
95
+ s[i + 2] = a ^ b ^ _mul(d, 2) ^ _mul(e, 3)
96
+ s[i + 3] = _mul(a, 3) ^ b ^ d ^ _mul(e, 2)
97
+
98
+
99
+ def _inv_mix_columns(s: list[int]) -> None:
100
+ for c in range(4):
101
+ i = c * 4
102
+ a, b, d, e = s[i], s[i + 1], s[i + 2], s[i + 3]
103
+ s[i] = _mul(a, 14) ^ _mul(b, 11) ^ _mul(d, 13) ^ _mul(e, 9)
104
+ s[i + 1] = _mul(a, 9) ^ _mul(b, 14) ^ _mul(d, 11) ^ _mul(e, 13)
105
+ s[i + 2] = _mul(a, 13) ^ _mul(b, 9) ^ _mul(d, 14) ^ _mul(e, 11)
106
+ s[i + 3] = _mul(a, 11) ^ _mul(b, 13) ^ _mul(d, 9) ^ _mul(e, 14)
107
+
108
+
109
+ def _encrypt_block(block: bytes, round_keys: list[list[int]]) -> bytes:
110
+ s = list(block)
111
+ _add_round_key(s, round_keys[0])
112
+ for r in range(1, 10):
113
+ _sub_bytes(s, _SBOX)
114
+ _shift_rows(s)
115
+ _mix_columns(s)
116
+ _add_round_key(s, round_keys[r])
117
+ _sub_bytes(s, _SBOX)
118
+ _shift_rows(s)
119
+ _add_round_key(s, round_keys[10])
120
+ return bytes(s)
121
+
122
+
123
+ def _decrypt_block(block: bytes, round_keys: list[list[int]]) -> bytes:
124
+ s = list(block)
125
+ _add_round_key(s, round_keys[10])
126
+ for r in range(9, 0, -1):
127
+ _inv_shift_rows(s)
128
+ _sub_bytes(s, _INV_SBOX)
129
+ _add_round_key(s, round_keys[r])
130
+ _inv_mix_columns(s)
131
+ _inv_shift_rows(s)
132
+ _sub_bytes(s, _INV_SBOX)
133
+ _add_round_key(s, round_keys[0])
134
+ return bytes(s)
135
+
136
+
137
+ def _pkcs7_pad(data: bytes) -> bytes:
138
+ n = 16 - (len(data) % 16)
139
+ return data + bytes([n] * n)
140
+
141
+
142
+ def _pkcs7_unpad(data: bytes) -> bytes:
143
+ if not data or len(data) % 16:
144
+ raise ValueError("invalid padding")
145
+ n = data[-1]
146
+ if n < 1 or n > 16 or data[-n:] != bytes([n] * n):
147
+ raise ValueError("invalid padding")
148
+ return data[:-n]
149
+
150
+
151
+ def aes_cbc_encrypt(key16: bytes, iv: bytes, plain: bytes) -> bytes:
152
+ if len(iv) != 16:
153
+ raise ValueError("IV must be 16 bytes")
154
+ round_keys = _expand_key(key16)
155
+ data = _pkcs7_pad(plain)
156
+ out = bytearray()
157
+ prev = iv
158
+ for i in range(0, len(data), 16):
159
+ block = bytes(a ^ b for a, b in zip(data[i : i + 16], prev))
160
+ enc = _encrypt_block(block, round_keys)
161
+ out.extend(enc)
162
+ prev = enc
163
+ return bytes(out)
164
+
165
+
166
+ def aes_cbc_decrypt(key16: bytes, iv: bytes, cipher: bytes) -> bytes:
167
+ if len(iv) != 16 or len(cipher) % 16:
168
+ raise ValueError("invalid ciphertext")
169
+ round_keys = _expand_key(key16)
170
+ out = bytearray()
171
+ prev = iv
172
+ for i in range(0, len(cipher), 16):
173
+ block = cipher[i : i + 16]
174
+ dec = _decrypt_block(block, round_keys)
175
+ out.extend(a ^ b for a, b in zip(dec, prev))
176
+ prev = block
177
+ return _pkcs7_unpad(bytes(out))
178
+
179
+
180
+ # --- Fernet(与 cryptography.fernet 兼容)---
181
+
182
+
183
+ class InvalidToken(Exception):
184
+ pass
185
+
186
+
187
+ class FernetLite:
188
+ """urlsafe-b64 32 字节 key:前 16 签名,后 16 加密。"""
189
+
190
+ def __init__(self, key: bytes | str):
191
+ if isinstance(key, str):
192
+ key = key.encode("ascii")
193
+ raw = base64.urlsafe_b64decode(key)
194
+ if len(raw) != 32:
195
+ raise ValueError("Fernet key must be 32 url-safe base64-encoded bytes")
196
+ self._signing_key = raw[:16]
197
+ self._encryption_key = raw[16:]
198
+
199
+ def encrypt(self, data: bytes) -> bytes:
200
+ iv = os.urandom(16)
201
+ ts = int(time.time())
202
+ ciphertext = aes_cbc_encrypt(self._encryption_key, iv, data)
203
+ basic = b"\x80" + struct.pack(">Q", ts) + iv + ciphertext
204
+ digest = hmac.new(self._signing_key, basic, hashlib.sha256).digest()
205
+ return base64.urlsafe_b64encode(basic + digest)
206
+
207
+ def decrypt(self, token: bytes, ttl: int | None = None) -> bytes:
208
+ try:
209
+ data = base64.urlsafe_b64decode(token)
210
+ except Exception as e:
211
+ raise InvalidToken("invalid token") from e
212
+ if len(data) < 1 + 8 + 16 + 16 + 32 or data[0] != 0x80:
213
+ raise InvalidToken("invalid token")
214
+ basic, digest = data[:-32], data[-32:]
215
+ expect = hmac.new(self._signing_key, basic, hashlib.sha256).digest()
216
+ if not hmac.compare_digest(digest, expect):
217
+ raise InvalidToken("invalid token")
218
+ ts = struct.unpack(">Q", basic[1:9])[0]
219
+ if ttl is not None and int(time.time()) - ts > ttl:
220
+ raise InvalidToken("token expired")
221
+ iv = basic[9:25]
222
+ ciphertext = basic[25:]
223
+ try:
224
+ return aes_cbc_decrypt(self._encryption_key, iv, ciphertext)
225
+ except ValueError as e:
226
+ raise InvalidToken("invalid token") from e
227
+
228
+
229
+ def derive_fernet_key(password: str, salt: bytes, iterations: int = 390_000) -> bytes:
230
+ raw = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations, dklen=32)
231
+ return base64.urlsafe_b64encode(raw)
pyfernet/loader.py ADDED
@@ -0,0 +1,195 @@
1
+ """解密 .enc 到内存 VFS 并执行;源码文件不落盘。
2
+
3
+ 磁盘上只建空目录树(供 Path(__file__)/chdir);内容经 open/Path/import hook 从内存读。
4
+ 进程结束删除空目录。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import getpass
10
+ import io
11
+ import json
12
+ import os
13
+ import shutil
14
+ import sys
15
+ import tempfile
16
+ import types
17
+ import zipfile
18
+ from pathlib import Path
19
+
20
+ from pyfernet.constants import KDF_ITERATIONS, MAGIC, MANIFEST_NAME, SALT_LEN
21
+ from pyfernet.fernet_lite import FernetLite, InvalidToken, derive_fernet_key
22
+ from pyfernet.vfs import PayloadVFS
23
+
24
+
25
+ def _configure_stdio() -> None:
26
+ """避免非 TTY / nohup / 管道下 stdout 块缓冲导致日志不实时。"""
27
+ os.environ.setdefault("PYTHONUNBUFFERED", "1")
28
+ for stream in (sys.stdout, sys.stderr):
29
+ try:
30
+ stream.reconfigure(line_buffering=True) # type: ignore[attr-defined]
31
+ except Exception:
32
+ try:
33
+ stream.reconfigure(write_through=True) # type: ignore[attr-defined]
34
+ except Exception:
35
+ pass
36
+
37
+
38
+ def decrypt_payload(enc_path: Path, password: str) -> bytes:
39
+ raw = enc_path.read_bytes()
40
+ if not raw.startswith(MAGIC):
41
+ raise ValueError(f"不是 PyFernet 密文包(magic 不匹配): {enc_path}")
42
+ salt = raw[len(MAGIC) : len(MAGIC) + SALT_LEN]
43
+ token = raw[len(MAGIC) + SALT_LEN :]
44
+ key = derive_fernet_key(password, salt, KDF_ITERATIONS)
45
+ try:
46
+ return FernetLite(key).decrypt(token)
47
+ except InvalidToken as e:
48
+ raise ValueError("口令错误或密文损坏") from e
49
+
50
+
51
+ def _empty_root() -> Path:
52
+ """仅作路径锚点的空目录(Linux 优先 /dev/shm)。"""
53
+ base: str | None = None
54
+ if sys.platform.startswith("linux"):
55
+ shm = Path("/dev/shm")
56
+ if shm.is_dir():
57
+ base = str(shm)
58
+ return Path(tempfile.mkdtemp(prefix="pyfernet_", dir=base))
59
+
60
+
61
+ def _zip_to_mapping(zip_bytes: bytes) -> tuple[dict[str, bytes], str]:
62
+ mapping: dict[str, bytes] = {}
63
+ with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zf:
64
+ if MANIFEST_NAME not in zf.namelist():
65
+ raise ValueError("密文包缺少 manifest")
66
+ manifest = json.loads(zf.read(MANIFEST_NAME).decode("utf-8"))
67
+ entry = str(manifest["entry_point"]).replace("\\", "/")
68
+ for info in zf.infolist():
69
+ name = info.filename.replace("\\", "/")
70
+ if name.endswith("/") or name == MANIFEST_NAME:
71
+ continue
72
+ mapping[name] = zf.read(info)
73
+ return mapping, entry
74
+
75
+
76
+ def list_payload(payload_path: str | Path, password: str) -> tuple[str, list[str]]:
77
+ """解密并返回 (entry_point, 相对路径列表)。"""
78
+ zip_bytes = decrypt_payload(Path(payload_path), password)
79
+ mapping, entry = _zip_to_mapping(zip_bytes)
80
+ return entry, sorted(mapping.keys())
81
+
82
+
83
+ def run_entry(zip_bytes: bytes, argv: list[str] | None = None) -> None:
84
+ _configure_stdio()
85
+ mapping, entry_rel = _zip_to_mapping(zip_bytes)
86
+ if entry_rel not in mapping:
87
+ raise RuntimeError(f"入口不在密文包内: {entry_rel}")
88
+
89
+ root = _empty_root()
90
+ vfs = PayloadVFS(root, mapping)
91
+ vfs.ensure_dir_tree()
92
+ vfs.install()
93
+ try:
94
+ entry = (root / entry_rel).resolve()
95
+ if argv is not None:
96
+ sys.argv = list(argv)
97
+
98
+ root_s = str(root)
99
+ entry_dir = str(entry.parent)
100
+ for p in (entry_dir, root_s):
101
+ if p in sys.path:
102
+ sys.path.remove(p)
103
+ sys.path.insert(0, p)
104
+
105
+ source = vfs.read_bytes(entry)
106
+ main_mod = types.ModuleType("__main__")
107
+ main_mod.__file__ = str(entry)
108
+ parent_pkg = entry_rel.replace("\\", "/").rsplit("/", 1)
109
+ if len(parent_pkg) == 2:
110
+ main_mod.__package__ = parent_pkg[0].replace("/", ".")
111
+ else:
112
+ main_mod.__package__ = ""
113
+ sys.modules["__main__"] = main_mod
114
+ code = compile(source, str(entry), "exec")
115
+ try:
116
+ exec(code, main_mod.__dict__)
117
+ except FileNotFoundError as e:
118
+ missing = str(e)
119
+ hint = _missing_pack_hint(missing, root, mapping)
120
+ if hint:
121
+ raise FileNotFoundError(f"{e}\n{hint}") from e
122
+ raise
123
+ finally:
124
+ vfs.uninstall()
125
+ shutil.rmtree(root, ignore_errors=True)
126
+
127
+
128
+ def _missing_pack_hint(err: str, root: Path, mapping: dict[str, bytes]) -> str:
129
+ """若缺失路径落在 VFS 根下,提示密文包未打包该相对路径。"""
130
+ root_s = str(root.resolve())
131
+ # FileNotFoundError 可能是 "缺少脚本: /path" 或纯路径
132
+ path_str = err
133
+ for prefix in ("缺少脚本: ", "配置不存在: ", "[Errno 2] No such file or directory: "):
134
+ if prefix in err:
135
+ path_str = err.split(prefix, 1)[-1].strip().strip("'\"")
136
+ break
137
+ try:
138
+ rel = Path(path_str).resolve().relative_to(Path(root_s))
139
+ except Exception:
140
+ return ""
141
+ rel_s = rel.as_posix()
142
+ if rel_s in mapping:
143
+ return ""
144
+ top = rel_s.split("/", 1)[0]
145
+ siblings = sorted({p.split("/", 1)[0] for p in mapping})
146
+ return (
147
+ f"pyfernet: 密文包内没有 `{rel_s}`。\n"
148
+ f" 当前包内顶层目录/文件: {siblings}\n"
149
+ f" 请把入口依赖的兄弟目录一起打进加密源目录后重新 encrypt"
150
+ + (f"(例如补上 `{top}/`)。" if top else "。")
151
+ )
152
+
153
+
154
+ def run_payload(
155
+ payload_path: str | Path,
156
+ password: str,
157
+ argv: list[str] | None = None,
158
+ ) -> None:
159
+ """解密并执行密文包(库接口)。"""
160
+ path = Path(payload_path)
161
+ if not path.is_file():
162
+ raise FileNotFoundError(f"找不到密文包: {path}")
163
+ zip_bytes = decrypt_payload(path, password)
164
+ run_entry(zip_bytes, argv=argv)
165
+
166
+
167
+ def main(
168
+ payload_path: str,
169
+ password: str | None = None,
170
+ argv: list[str] | None = None,
171
+ ) -> None:
172
+ path = Path(payload_path)
173
+ if not path.is_file():
174
+ raise SystemExit(f"找不到密文包: {path}")
175
+
176
+ if password is None:
177
+ password = getpass.getpass("解密口令: ")
178
+
179
+ run_payload(path, password, argv=argv)
180
+
181
+
182
+ def _ide_main() -> None:
183
+ PAYLOAD_PATH = "dist/train_payload.enc"
184
+ PASSWORD = None
185
+ TRAIN_ARGV = ["train.py"]
186
+
187
+ main(payload_path=PAYLOAD_PATH, password=PASSWORD, argv=TRAIN_ARGV)
188
+
189
+
190
+ if __name__ == "__main__":
191
+ if len(sys.argv) > 1:
192
+ from pyfernet.cli import main as cli_main
193
+
194
+ raise SystemExit(cli_main(["run", *sys.argv[1:]]))
195
+ _ide_main()
pyfernet/py.typed ADDED
@@ -0,0 +1 @@
1
+ # 标记本包支持类型检查(无单独 stub)
pyfernet/vfs.py ADDED
@@ -0,0 +1,251 @@
1
+ """内存 VFS:包内文件不落盘,Path/open/import 从内存读。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import importlib.abc
7
+ import importlib.machinery
8
+ import importlib.util
9
+ import io
10
+ import os
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Callable
14
+
15
+
16
+ class PayloadVFS:
17
+ """root 下仅有空目录;文件内容在 mapping(相对 posix 路径 → bytes)。"""
18
+
19
+ def __init__(self, root: Path, mapping: dict[str, bytes]):
20
+ self.root = root.resolve()
21
+ self.rel_files = {k.replace("\\", "/"): v for k, v in mapping.items()}
22
+ self.abs_files: dict[str, bytes] = {}
23
+ for rel, data in self.rel_files.items():
24
+ abs_p = (self.root / rel).resolve()
25
+ self.abs_files[str(abs_p)] = data
26
+ self.abs_files[abs_p.as_posix()] = data
27
+
28
+ self._orig_open = builtins.open
29
+ self._orig_path_open = Path.open
30
+ self._orig_read_text = Path.read_text
31
+ self._orig_read_bytes = Path.read_bytes
32
+ self._orig_is_file = Path.is_file
33
+ self._orig_exists = Path.exists
34
+ self._orig_os_path_isfile = os.path.isfile
35
+ self._orig_os_path_exists = os.path.exists
36
+ self._orig_spec_from_file_location = importlib.util.spec_from_file_location
37
+ self._path_hook: Callable | None = None
38
+ self._installed = False
39
+
40
+ def _norm_key(self, path: str | Path) -> str | None:
41
+ try:
42
+ p = Path(path)
43
+ if not p.is_absolute():
44
+ p = Path.cwd() / p
45
+ key = str(p.resolve())
46
+ except Exception:
47
+ key = str(path)
48
+ if key in self.abs_files:
49
+ return key
50
+ try:
51
+ alt = Path(path)
52
+ key2 = str((self.root / alt).resolve()) if not alt.is_absolute() else str(alt.resolve())
53
+ if key2 in self.abs_files:
54
+ return key2
55
+ except Exception:
56
+ pass
57
+ return None
58
+
59
+ def has(self, path: str | Path) -> bool:
60
+ return self._norm_key(path) is not None
61
+
62
+ def read_bytes(self, path: str | Path) -> bytes:
63
+ key = self._norm_key(path)
64
+ if key is None:
65
+ raise FileNotFoundError(path)
66
+ return self.abs_files[key]
67
+
68
+ def managed_dir(self, path: str | Path) -> bool:
69
+ try:
70
+ Path(path).resolve().relative_to(self.root)
71
+ return True
72
+ except Exception:
73
+ return False
74
+
75
+ def ensure_dir_tree(self) -> None:
76
+ """只创建空目录,不写任何文件内容。"""
77
+ self.root.mkdir(parents=True, exist_ok=True)
78
+ for rel in self.rel_files:
79
+ (self.root / rel).parent.mkdir(parents=True, exist_ok=True)
80
+
81
+ def install(self) -> None:
82
+ if self._installed:
83
+ return
84
+ vfs = self
85
+
86
+ def open_hook(file, mode="r", *args, **kwargs):
87
+ if isinstance(file, (str, os.PathLike)) and vfs.has(file):
88
+ data = vfs.read_bytes(file)
89
+ if "b" in mode:
90
+ return io.BytesIO(data)
91
+ enc = kwargs.get("encoding") or "utf-8"
92
+ return io.StringIO(data.decode(enc))
93
+ return vfs._orig_open(file, mode, *args, **kwargs)
94
+
95
+ def path_open(self: Path, mode="r", *args, **kwargs):
96
+ if vfs.has(self):
97
+ return open_hook(self, mode, *args, **kwargs)
98
+ return vfs._orig_path_open(self, mode, *args, **kwargs)
99
+
100
+ def path_read_text(self: Path, encoding="utf-8", errors="strict"):
101
+ if vfs.has(self):
102
+ return vfs.read_bytes(self).decode(encoding, errors)
103
+ return vfs._orig_read_text(self, encoding=encoding, errors=errors)
104
+
105
+ def path_read_bytes(self: Path):
106
+ if vfs.has(self):
107
+ return vfs.read_bytes(self)
108
+ return vfs._orig_read_bytes(self)
109
+
110
+ def path_is_file(self: Path):
111
+ if vfs.has(self):
112
+ return True
113
+ return vfs._orig_is_file(self)
114
+
115
+ def path_exists(self: Path):
116
+ if vfs.has(self):
117
+ return True
118
+ return vfs._orig_exists(self)
119
+
120
+ def os_isfile(path):
121
+ if vfs.has(path):
122
+ return True
123
+ return vfs._orig_os_path_isfile(path)
124
+
125
+ def os_exists(path):
126
+ if vfs.has(path):
127
+ return True
128
+ return vfs._orig_os_path_exists(path)
129
+
130
+ def spec_from_file_location(name, location, *args, **kwargs):
131
+ if location is not None and vfs.has(location):
132
+ loc = str(Path(location).resolve())
133
+ source = vfs.read_bytes(location)
134
+ is_pkg = loc.endswith(f"{os.sep}__init__.py") or loc.endswith("/__init__.py")
135
+ loader = _VfsSourceLoader(name, loc, source, is_package=is_pkg)
136
+ spec = importlib.machinery.ModuleSpec(
137
+ name, loader, origin=loc, is_package=is_pkg
138
+ )
139
+ if is_pkg:
140
+ spec.submodule_search_locations = [str(Path(loc).parent)]
141
+ return spec
142
+ return vfs._orig_spec_from_file_location(name, location, *args, **kwargs)
143
+
144
+ def path_hook(entry):
145
+ if vfs.managed_dir(entry):
146
+ return _VfsPathFinder(vfs, str(Path(entry).resolve()))
147
+ raise ImportError("not a pyfernet vfs path")
148
+
149
+ builtins.open = open_hook # type: ignore[assignment]
150
+ Path.open = path_open # type: ignore[method-assign, assignment]
151
+ Path.read_text = path_read_text # type: ignore[method-assign, assignment]
152
+ Path.read_bytes = path_read_bytes # type: ignore[method-assign, assignment]
153
+ Path.is_file = path_is_file # type: ignore[method-assign, assignment]
154
+ Path.exists = path_exists # type: ignore[method-assign, assignment]
155
+ os.path.isfile = os_isfile # type: ignore[assignment]
156
+ os.path.exists = os_exists # type: ignore[assignment]
157
+ importlib.util.spec_from_file_location = spec_from_file_location # type: ignore[assignment]
158
+
159
+ self._path_hook = path_hook
160
+ sys.path_hooks.insert(0, path_hook)
161
+ sys.path_importer_cache.clear()
162
+ importlib.invalidate_caches()
163
+ self._installed = True
164
+
165
+ def uninstall(self) -> None:
166
+ if not self._installed:
167
+ return
168
+ builtins.open = self._orig_open # type: ignore[assignment]
169
+ Path.open = self._orig_path_open # type: ignore[method-assign, assignment]
170
+ Path.read_text = self._orig_read_text # type: ignore[method-assign, assignment]
171
+ Path.read_bytes = self._orig_read_bytes # type: ignore[method-assign, assignment]
172
+ Path.is_file = self._orig_is_file # type: ignore[method-assign, assignment]
173
+ Path.exists = self._orig_exists # type: ignore[method-assign, assignment]
174
+ os.path.isfile = self._orig_os_path_isfile # type: ignore[assignment]
175
+ os.path.exists = self._orig_os_path_exists # type: ignore[assignment]
176
+ importlib.util.spec_from_file_location = self._orig_spec_from_file_location # type: ignore[assignment]
177
+ if self._path_hook is not None:
178
+ try:
179
+ sys.path_hooks.remove(self._path_hook)
180
+ except ValueError:
181
+ pass
182
+ sys.path_importer_cache.clear()
183
+ importlib.invalidate_caches()
184
+ self._installed = False
185
+
186
+
187
+ class _VfsSourceLoader(importlib.abc.Loader):
188
+ def __init__(self, name: str, path: str, source: bytes, is_package: bool = False):
189
+ self.name = name
190
+ self.path = path
191
+ self.source = source
192
+ self.is_package = is_package
193
+
194
+ def create_module(self, spec):
195
+ return None
196
+
197
+ def exec_module(self, module):
198
+ module.__file__ = self.path
199
+ if self.is_package:
200
+ module.__path__ = [str(Path(self.path).parent)]
201
+ module.__package__ = self.name
202
+ else:
203
+ parent, _, _ = self.name.rpartition(".")
204
+ module.__package__ = parent
205
+ code = compile(self.source, self.path, "exec")
206
+ exec(code, module.__dict__)
207
+
208
+ def get_filename(self, fullname):
209
+ return self.path
210
+
211
+ def is_package(self, fullname):
212
+ return self.is_package
213
+
214
+ def get_data(self, path):
215
+ with open(path, "rb") as f:
216
+ return f.read()
217
+
218
+
219
+ class _VfsPathFinder(importlib.abc.PathEntryFinder):
220
+ """行为对齐 importlib.machinery.FileFinder:只用 fullname 最后一段。"""
221
+
222
+ def __init__(self, vfs: PayloadVFS, path_entry: str):
223
+ self.vfs = vfs
224
+ self.path_entry = path_entry
225
+
226
+ def find_spec(self, fullname, target=None):
227
+ base = Path(self.path_entry)
228
+ # 与 FileFinder 一致:path 已是包目录或 sys.path 项时,只找 tail
229
+ tail = fullname.rpartition(".")[2]
230
+ file_cand = base / f"{tail}.py"
231
+ pkg_cand = base / tail / "__init__.py"
232
+
233
+ if self.vfs.has(pkg_cand):
234
+ origin = str(pkg_cand.resolve())
235
+ source = self.vfs.read_bytes(pkg_cand)
236
+ loader = _VfsSourceLoader(fullname, origin, source, is_package=True)
237
+ spec = importlib.machinery.ModuleSpec(
238
+ fullname, loader, origin=origin, is_package=True
239
+ )
240
+ spec.submodule_search_locations = [str(pkg_cand.parent.resolve())]
241
+ return spec
242
+
243
+ if self.vfs.has(file_cand):
244
+ origin = str(file_cand.resolve())
245
+ source = self.vfs.read_bytes(file_cand)
246
+ loader = _VfsSourceLoader(fullname, origin, source, is_package=False)
247
+ return importlib.machinery.ModuleSpec(
248
+ fullname, loader, origin=origin, is_package=False
249
+ )
250
+
251
+ return None
@@ -0,0 +1,151 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyfernet-payload
3
+ Version: 0.2.4
4
+ Summary: Encrypt a Python source directory into one .enc file and run it from memory (Fernet AES, stdlib-only).
5
+ Home-page: https://github.com/yinshunyao/PyFernet
6
+ Author: shunyaoyin
7
+ License-Expression: MIT
8
+ Project-URL: Homepage, https://github.com/yinshunyao/PyFernet
9
+ Project-URL: Issues, https://github.com/yinshunyao/PyFernet/issues
10
+ Project-URL: Documentation, https://github.com/yinshunyao/PyFernet#readme
11
+ Keywords: fernet,aes,encrypt,source-protection,in-memory,training
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Security :: Cryptography
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.10
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Dynamic: home-page
29
+ Dynamic: license-file
30
+ Dynamic: requires-python
31
+
32
+ # PyFernet (`pyfernet-payload`)
33
+
34
+ [中文说明](README.zh-CN.md)
35
+
36
+ Pack a training source directory into a single `.enc` blob; on the client, decrypt and run from an **in-memory VFS**. Only an **empty directory tree** is created on disk (for `Path(__file__)` / `chdir`); `.py` / config **contents are never written**. Removed on exit.
37
+
38
+ - **Zero third-party runtime deps** (stdlib AES-128-CBC + Fernet-compatible format)
39
+ - **PyPI name**: `pyfernet-payload` (`pyfernet` is already taken)
40
+ - **CLI / import**: `pyfernet` / `import pyfernet`
41
+ - **Relative paths work**: `Path(__file__).parent / "train_config.json"`, `open`, `spec_from_file_location` via VFS hooks
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install pyfernet-payload
47
+ ```
48
+
49
+ From a local clone:
50
+
51
+ ```bash
52
+ # preferred on older pip / mirrors (no editable hook needed)
53
+ python3 -m pip install .
54
+
55
+ # or editable (dev); if this fails, upgrade pip first:
56
+ python3 -m pip install -U pip setuptools wheel
57
+ python3 -m pip install -e .
58
+ ```
59
+ ## CLI
60
+
61
+ ### Encrypt
62
+
63
+ ```bash
64
+ pyfernet encrypt ./my_train -o train_payload.enc -e train.py
65
+ # passphrase via prompt; or:
66
+ export PYFERNET_PASSWORD='your-secret'
67
+ pyfernet encrypt ./my_train -o train_payload.enc -e train.py --password-env PYFERNET_PASSWORD
68
+ ```
69
+
70
+ ### Run
71
+
72
+ ```bash
73
+ pyfernet run train_payload.enc
74
+ # forward args to the training entry (after --):
75
+ pyfernet run train_payload.enc -- --epochs 50 --batch 8
76
+ ```
77
+
78
+ Equivalent:
79
+
80
+ ```bash
81
+ python -m pyfernet encrypt ./my_train -o train_payload.enc -e train.py
82
+ python -m pyfernet run train_payload.enc
83
+ ```
84
+
85
+ ## Workflow
86
+
87
+ 1. **Local**: write training code → `pyfernet encrypt` → `train_payload.enc`
88
+ 2. **SFTP**: upload the ciphertext only (client already has `pip install pyfernet-payload`)
89
+ 3. **SSH**: `pyfernet run train_payload.enc`, enter passphrase
90
+ 4. Decrypt into memory → empty dir anchors + VFS hooks → start training; write weights to a normal disk path
91
+ 5. On exit, remove empty dirs; durable disk still has only ciphertext
92
+
93
+ If the entry uses sibling dirs (e.g. `train_detect_rtdetrv2/` + `train_detect_cfg/` + `train_detect_yolo/`), **encrypt the parent folder** and set `-e train_detect_rtdetrv2/train_insect.py`.
94
+
95
+ Inference code, datasets, and weights can stay plaintext on the client.
96
+
97
+ ## IDE variable mode (optional)
98
+
99
+ With no CLI args, modules use the variables at the bottom of the file:
100
+
101
+ ```bash
102
+ python -m pyfernet.encryptor # no args → SOURCE_DIR / OUTPUT_PATH / ENTRY_POINT
103
+ python -m pyfernet.loader # no args → PAYLOAD_PATH / TRAIN_ARGV
104
+ ```
105
+
106
+ With args, they use the CLI, e.g. `python -m pyfernet.encryptor ./src -o out.enc -e train.py`.
107
+
108
+ ## Library API
109
+
110
+ ```python
111
+ from pyfernet import encrypt_directory, run_payload
112
+
113
+ encrypt_directory("examples/demo_train", "dist/train_payload.enc", "train.py", "secret")
114
+ run_payload("dist/train_payload.enc", "secret", argv=["train.py", "--epochs", "1"])
115
+ ```
116
+
117
+ ## Payload format
118
+
119
+ ```text
120
+ PYFE1\0 + 16B salt + Fernet(token)
121
+ ```
122
+
123
+ Fernet plaintext is a zip (sources + `_pyfernet_manifest.json`). The passphrase is stretched with PBKDF2-HMAC-SHA256 (390000 iterations).
124
+
125
+ ## Notes
126
+
127
+ - Source file **bytes never hit disk**; only empty directories exist briefly (often under `/dev/shm` on Linux)
128
+ - Process memory / debuggers can still see plaintext; a leaked passphrase decrypts the blob
129
+ - By default only `.py` and a few config suffixes are packed—do not put datasets or weights in the blob
130
+ - The client still needs training deps installed (torch, ultralytics, etc.)
131
+
132
+ ## Publish to PyPI
133
+
134
+ See [docs/PUBLISH.md](docs/PUBLISH.md) ([中文](docs/PUBLISH.zh-CN.md)).
135
+
136
+ ## Layout
137
+
138
+ ```text
139
+ PyFernet/
140
+ pyproject.toml
141
+ README.md # English (PyPI)
142
+ README.zh-CN.md # Chinese
143
+ src/pyfernet/
144
+ cli.py
145
+ encryptor.py
146
+ loader.py
147
+ fernet_lite.py
148
+ examples/demo_train/
149
+ docs/PUBLISH.md
150
+ docs/PUBLISH.zh-CN.md
151
+ ```
@@ -0,0 +1,15 @@
1
+ pyfernet/__init__.py,sha256=GrJ4DeIiGby-l6p9cB2HS68wH6z1z5brbtSNvAKzMt4,313
2
+ pyfernet/__main__.py,sha256=CganAFN0J0O8bAwcENySBGUQ4XqDSqbp5cvltMxYtN0,160
3
+ pyfernet/cli.py,sha256=ciUE71bjbVCqr4nor9YGobuS0sud2KsByMY29aynxKE,5555
4
+ pyfernet/constants.py,sha256=CXW0GkMAs-9saRRn57UhS8oOCl4Qal1s6q6yXdvDOXU,516
5
+ pyfernet/encryptor.py,sha256=TRQwfQqcaeSkwZWl6sHInHctaDSyzhIg9p2cJd-AFPo,4091
6
+ pyfernet/fernet_lite.py,sha256=iN3WxA_JTbz_LS7xXG7gOliGv3DL7Txxviy-9177y1E,8414
7
+ pyfernet/loader.py,sha256=V3XzFRFyvbpwHhmIl7YSVaZXY2OHG0FLuI_cppT1K9Y,6501
8
+ pyfernet/py.typed,sha256=gTk4rCnF8L8nqyz-gwOZm96JOkosxmWl8kJbPk6PzPU,53
9
+ pyfernet/vfs.py,sha256=K5fujSgQExqNttpvpFVk3YrwEhuMluBQyWmXQ19brxQ,9670
10
+ pyfernet_payload-0.2.4.dist-info/licenses/LICENSE,sha256=lVKlwS2wB5VI_fWciEFSLQ3BtH_rytZxueolGL69Kpk,1067
11
+ pyfernet_payload-0.2.4.dist-info/METADATA,sha256=WUbjQj9FPqFcpNtEE0KkLWGSpFDM_ZS-bZYuuQURTKo,5121
12
+ pyfernet_payload-0.2.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ pyfernet_payload-0.2.4.dist-info/entry_points.txt,sha256=nluXpMn3K9qt0Cx-DpEkp3lQ9Kd0s3PgirpQkVUIl7Q,47
14
+ pyfernet_payload-0.2.4.dist-info/top_level.txt,sha256=u17EFBp7_dRhGP3rVq7kr2gEj6U1DJenrpRcdJZQqpU,9
15
+ pyfernet_payload-0.2.4.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
+ pyfernet = pyfernet.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 shunyaoyin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ pyfernet