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 +14 -0
- pyfernet/__main__.py +8 -0
- pyfernet/cli.py +158 -0
- pyfernet/constants.py +31 -0
- pyfernet/encryptor.py +143 -0
- pyfernet/fernet_lite.py +231 -0
- pyfernet/loader.py +195 -0
- pyfernet/py.typed +1 -0
- pyfernet/vfs.py +251 -0
- pyfernet_payload-0.2.4.dist-info/METADATA +151 -0
- pyfernet_payload-0.2.4.dist-info/RECORD +15 -0
- pyfernet_payload-0.2.4.dist-info/WHEEL +5 -0
- pyfernet_payload-0.2.4.dist-info/entry_points.txt +2 -0
- pyfernet_payload-0.2.4.dist-info/licenses/LICENSE +21 -0
- pyfernet_payload-0.2.4.dist-info/top_level.txt +1 -0
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
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()
|
pyfernet/fernet_lite.py
ADDED
|
@@ -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,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
|