feat: 添加命令行接口与文档,改进错误处理
- 使用 argparse 提供 encrypt/decrypt 子命令,支持交互式密码与分卷大小解析 - 增加输入目录检查、密码错误提示和分卷识别容错 - 添加 .gitignore、LICENSE 和 README.md - 将 CFB 模式迁移至 decrepit 模块并提取加密参数常量
这个提交包含在:
+10
-1
@@ -1 +1,10 @@
|
|||||||
.venv
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# 加密/解密产物(运行生成)
|
||||||
|
encrypted/
|
||||||
|
decrypted/
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 wangchuanli
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# File Encrypt
|
||||||
|
|
||||||
|
一个基于密码的命令行文件加密工具,使用 [`cryptography`](https://pypi.org/project/cryptography/) 库实现。对目录内文件执行 **压缩 → 加密 → 分卷存储**,并可完整还原。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- **密钥派生**:PBKDF2-HMAC(SHA256)从密码派生 32 字节 AES 密钥,随机 salt,迭代 100,000 次,抵御暴力破解。
|
||||||
|
- **AES 加密**:AES-256-CFB 模式,每个文件使用随机 IV(初始化向量)。
|
||||||
|
- **压缩**:加密前用 `zlib` 压缩,减小体积。
|
||||||
|
- **分卷存储**:加密后数据超过分卷大小时自动切分为多个 `.partN` 文件;否则保存为单个 `.enc` 文件。
|
||||||
|
- **批量处理**:递归加密/解密整个目录。
|
||||||
|
- **命令行友好**:密码通过参数或交互式输入,绝不写入源码;分卷大小支持 `KB/MB/GB` 后缀。
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
**加密**:读文件 → `zlib` 压缩 → 随机 salt + PBKDF2 派生密钥 → 随机 IV + AES-CFB 加密 → 按 `volume_size` 分卷(salt 写入首个分卷)或存为单个 `.enc`(`salt + 密文`)。
|
||||||
|
|
||||||
|
**解密**:读取目录下所有分卷(或单个 `.enc`)拼接 → 取 salt 派生密钥 → AES 解密 → `zlib` 解压 → 还原原文件。
|
||||||
|
|
||||||
|
参数常量(`main.py` 顶部):
|
||||||
|
|
||||||
|
| 常量 | 默认值 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `SALT_SIZE` | 16 | salt 字节数 |
|
||||||
|
| `IV_SIZE` | 16 | IV 字节数 |
|
||||||
|
| `KEY_SIZE` | 32 | AES 密钥长度(256 位) |
|
||||||
|
| `PBKDF2_ITERATIONS` | 100000 | 密钥派生迭代次数 |
|
||||||
|
|
||||||
|
## 环境要求
|
||||||
|
|
||||||
|
- Python 3.7+
|
||||||
|
- `cryptography` 库
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 加密:将 ./data 加密输出到 ./encrypted
|
||||||
|
python main.py encrypt ./data ./encrypted -p your_password -v 10MB
|
||||||
|
|
||||||
|
# 解密:从 ./encrypted 还原到 ./decrypted
|
||||||
|
python main.py decrypt ./encrypted ./decrypted -p your_password
|
||||||
|
|
||||||
|
# 省略 -p 时交互式输入密码(推荐,避免密码出现在命令行历史)
|
||||||
|
python main.py encrypt ./data ./encrypted
|
||||||
|
```
|
||||||
|
|
||||||
|
### 参数说明
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `action` | `encrypt` 或 `decrypt`(子命令) |
|
||||||
|
| `source` | 加密时为源目录;解密时为加密数据目录 |
|
||||||
|
| `output` | 加密时为输出目录;解密时为还原输出目录 |
|
||||||
|
| `-p, --password` | 密码。省略则交互式输入 |
|
||||||
|
| `-v, --volume-size` | 分卷大小,支持 `KB/MB/GB` 后缀,默认 `10MB` |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── main.py # 主程序(命令行入口)
|
||||||
|
├── requirements.txt # 依赖声明
|
||||||
|
├── LICENSE # MIT 许可证
|
||||||
|
├── .gitignore
|
||||||
|
├── data/ # 待加密的原始数据(示例)
|
||||||
|
├── encrypted/ # 加密输出(运行后生成,已被 git 忽略)
|
||||||
|
└── decrypted/ # 解密输出(运行后生成,已被 git 忽略)
|
||||||
|
```
|
||||||
|
|
||||||
|
加密输出结构:
|
||||||
|
|
||||||
|
```
|
||||||
|
encrypted/
|
||||||
|
└── <原文件名>/
|
||||||
|
├── <原文件名>.enc # 未分卷(数据小于分卷大小)
|
||||||
|
└── <原文件名>.part1 # 分卷:首个分卷含 salt
|
||||||
|
<原文件名>.part2 # 其余分卷
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- **密码是解密的唯一凭据,请务必妥善保管;忘记密码无法恢复数据。**
|
||||||
|
- 解密需提供与加密完全相同的密码,密码错误会提示 `Failed to decrypt ... Wrong password or corrupted data.`。
|
||||||
|
- salt 与 IV 已随密文写入文件,无需单独保存。
|
||||||
|
- 当前为单文件实现,适合学习与小批量文件加密;对超大文件采用全量读入内存,后续可改为流式处理。
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[MIT](./LICENSE)
|
||||||
@@ -1,24 +1,32 @@
|
|||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
import zlib
|
import zlib
|
||||||
|
import argparse
|
||||||
|
import getpass
|
||||||
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
||||||
from cryptography.hazmat.primitives.hashes import SHA256
|
from cryptography.hazmat.primitives.hashes import SHA256
|
||||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
|
||||||
|
from cryptography.hazmat.decrepit.ciphers.modes import CFB
|
||||||
from cryptography.hazmat.backends import default_backend
|
from cryptography.hazmat.backends import default_backend
|
||||||
|
|
||||||
|
SALT_SIZE = 16
|
||||||
|
IV_SIZE = 16
|
||||||
|
KEY_SIZE = 32
|
||||||
|
PBKDF2_ITERATIONS = 100_000
|
||||||
|
|
||||||
# 辅助函数:使用AES加密数据
|
# 辅助函数:使用AES加密数据
|
||||||
def encrypt_data(key, data):
|
def encrypt_data(key, data):
|
||||||
iv = os.urandom(16) # 生成随机的初始化向量(IV)
|
iv = os.urandom(IV_SIZE) # 生成随机的初始化向量(IV)
|
||||||
cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend())
|
cipher = Cipher(algorithms.AES(key), CFB(iv), backend=default_backend())
|
||||||
encryptor = cipher.encryptor()
|
encryptor = cipher.encryptor()
|
||||||
encrypted_data = iv + encryptor.update(data) + encryptor.finalize()
|
encrypted_data = iv + encryptor.update(data) + encryptor.finalize()
|
||||||
return encrypted_data
|
return encrypted_data
|
||||||
|
|
||||||
# 辅助函数:使用AES解密数据
|
# 辅助函数:使用AES解密数据
|
||||||
def decrypt_data(key, data):
|
def decrypt_data(key, data):
|
||||||
iv = data[:16] # 提取初始化向量(IV)
|
iv = data[:IV_SIZE] # 提取初始化向量(IV)
|
||||||
encrypted_content = data[16:]
|
encrypted_content = data[IV_SIZE:]
|
||||||
cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend())
|
cipher = Cipher(algorithms.AES(key), CFB(iv), backend=default_backend())
|
||||||
decryptor = cipher.decryptor()
|
decryptor = cipher.decryptor()
|
||||||
decrypted_data = decryptor.update(encrypted_content) + decryptor.finalize()
|
decrypted_data = decryptor.update(encrypted_content) + decryptor.finalize()
|
||||||
return decrypted_data
|
return decrypted_data
|
||||||
@@ -27,9 +35,9 @@ def decrypt_data(key, data):
|
|||||||
def derive_key(password, salt):
|
def derive_key(password, salt):
|
||||||
kdf = PBKDF2HMAC(
|
kdf = PBKDF2HMAC(
|
||||||
algorithm=SHA256(),
|
algorithm=SHA256(),
|
||||||
length=32,
|
length=KEY_SIZE,
|
||||||
salt=salt,
|
salt=salt,
|
||||||
iterations=100000,
|
iterations=PBKDF2_ITERATIONS,
|
||||||
backend=default_backend()
|
backend=default_backend()
|
||||||
)
|
)
|
||||||
return kdf.derive(password.encode())
|
return kdf.derive(password.encode())
|
||||||
@@ -52,7 +60,7 @@ def encrypt_file(file_path, password, volume_size, output_dir):
|
|||||||
compressed_data = compress_data(data)
|
compressed_data = compress_data(data)
|
||||||
|
|
||||||
# 加密压缩后的内容
|
# 加密压缩后的内容
|
||||||
salt = os.urandom(16)
|
salt = os.urandom(SALT_SIZE)
|
||||||
key = derive_key(password, salt)
|
key = derive_key(password, salt)
|
||||||
encrypted_data = encrypt_data(key, compressed_data)
|
encrypted_data = encrypt_data(key, compressed_data)
|
||||||
|
|
||||||
@@ -80,31 +88,53 @@ def encrypt_file(file_path, password, volume_size, output_dir):
|
|||||||
with open(output_path, 'wb') as enc_file:
|
with open(output_path, 'wb') as enc_file:
|
||||||
enc_file.write(salt + encrypted_data)
|
enc_file.write(salt + encrypted_data)
|
||||||
|
|
||||||
print(f"Encrypted and saved: {file_path} to {encrypted_dir}")
|
print(f"Encrypted and saved: {file_path} -> {encrypted_dir}")
|
||||||
|
|
||||||
# 解密并解压单个文件或分卷
|
# 解密并解压单个文件或分卷
|
||||||
def decrypt_file(encrypted_dir, password, output_dir):
|
def decrypt_file(encrypted_dir, password, output_dir):
|
||||||
# 读取所有分卷或单个加密文件
|
# 读取所有分卷或单个加密文件
|
||||||
parts = [os.path.join(encrypted_dir, f) for f in os.listdir(encrypted_dir) if f.rsplit('.')[-1].startswith("part") or f.endswith(".enc")]
|
try:
|
||||||
|
entries = os.listdir(encrypted_dir)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"[ERROR] Encrypted directory not found: {encrypted_dir}")
|
||||||
|
return
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for f in entries:
|
||||||
|
# 分卷文件名形如 <name>.part1 / .part2 ...;单文件为 <name>.enc
|
||||||
|
base, _, ext = f.rpartition('.')
|
||||||
|
if ext.startswith("part") or ext == "enc":
|
||||||
|
parts.append(os.path.join(encrypted_dir, f))
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
print(f"[WARN] No encrypted parts found in: {encrypted_dir}")
|
||||||
|
return
|
||||||
|
|
||||||
parts = sorted(parts) # 确保按顺序读取
|
parts = sorted(parts) # 确保按顺序读取
|
||||||
print(f"Parts found: {parts}")
|
|
||||||
encrypted_data = b""
|
encrypted_data = b""
|
||||||
salt = None
|
salt = None
|
||||||
for i, part in enumerate(parts):
|
for i, part in enumerate(parts):
|
||||||
with open(part, 'rb') as part_file:
|
with open(part, 'rb') as part_file:
|
||||||
if i == 0:
|
if i == 0:
|
||||||
salt = part_file.read(16)
|
# 首个分卷(或单文件 .enc)的前 SALT_SIZE 字节为 salt
|
||||||
|
salt = part_file.read(SALT_SIZE)
|
||||||
encrypted_data += part_file.read()
|
encrypted_data += part_file.read()
|
||||||
|
|
||||||
if salt is None:
|
if not salt or len(salt) != SALT_SIZE:
|
||||||
raise ValueError("Salt not found in the first part.")
|
print(f"[ERROR] Invalid salt in first part of: {encrypted_dir}")
|
||||||
|
return
|
||||||
|
|
||||||
# 解密数据
|
# 解密数据
|
||||||
|
try:
|
||||||
key = derive_key(password, salt)
|
key = derive_key(password, salt)
|
||||||
decrypted_data = decrypt_data(key, encrypted_data)
|
decrypted_data = decrypt_data(key, encrypted_data)
|
||||||
|
# 解压数据(密码错误时这里会抛异常)
|
||||||
# 解压数据
|
|
||||||
decompressed_data = decompress_data(decrypted_data)
|
decompressed_data = decompress_data(decrypted_data)
|
||||||
|
except (zlib.error, ValueError):
|
||||||
|
print(f"[ERROR] Failed to decrypt {encrypted_dir}. Wrong password or corrupted data.")
|
||||||
|
return
|
||||||
|
|
||||||
output_file = os.path.join(output_dir, os.path.basename(encrypted_dir))
|
output_file = os.path.join(output_dir, os.path.basename(encrypted_dir))
|
||||||
with open(output_file, 'wb') as f:
|
with open(output_file, 'wb') as f:
|
||||||
f.write(decompressed_data)
|
f.write(decompressed_data)
|
||||||
@@ -113,6 +143,10 @@ def decrypt_file(encrypted_dir, password, output_dir):
|
|||||||
|
|
||||||
# 加密文件夹中的所有文件
|
# 加密文件夹中的所有文件
|
||||||
def encrypt_directory(directory, password, volume_size, output_dir):
|
def encrypt_directory(directory, password, volume_size, output_dir):
|
||||||
|
if not os.path.isdir(directory):
|
||||||
|
print(f"[ERROR] Source directory not found: {directory}")
|
||||||
|
sys.exit(1)
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
for root, _, files in os.walk(directory):
|
for root, _, files in os.walk(directory):
|
||||||
for file in files:
|
for file in files:
|
||||||
file_path = os.path.join(root, file)
|
file_path = os.path.join(root, file)
|
||||||
@@ -120,23 +154,61 @@ def encrypt_directory(directory, password, volume_size, output_dir):
|
|||||||
|
|
||||||
# 解密文件夹中的所有加密文件
|
# 解密文件夹中的所有加密文件
|
||||||
def decrypt_directory(directory, password, output_dir):
|
def decrypt_directory(directory, password, output_dir):
|
||||||
|
if not os.path.isdir(directory):
|
||||||
|
print(f"[ERROR] Encrypted directory not found: {directory}")
|
||||||
|
sys.exit(1)
|
||||||
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
for root, dirs, _ in os.walk(directory):
|
for root, dirs, _ in os.walk(directory):
|
||||||
for dir in dirs:
|
for d in dirs:
|
||||||
encrypted_dir = os.path.join(root, dir)
|
encrypted_dir = os.path.join(root, d)
|
||||||
decrypt_file(encrypted_dir, password, output_dir)
|
decrypt_file(encrypted_dir, password, output_dir)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
def parse_volume_size(text):
|
||||||
action = "encrypt" # 或 "decrypt"
|
"""解析分卷大小,支持 KB/MB/GB 后缀,如 10MB。"""
|
||||||
# action = "decrypt" # 或 "decrypt"
|
units = {"KB": 1024, "MB": 1024**2, "GB": 1024**3}
|
||||||
password = "123456"
|
text = text.strip().upper()
|
||||||
volume_size = 10 * 1024 * 1024 # 每个分卷大小(10 MB)
|
for suffix, factor in units.items():
|
||||||
source_dir = "./data" # 原始数据文件夹
|
if text.endswith(suffix):
|
||||||
encrypted_dir = "./encrypted" # 加密文件夹
|
return int(text[:-len(suffix)]) * factor
|
||||||
decrypted_dir = "./decrypted" # 解密文件夹
|
return int(text) # 纯数字视为字节
|
||||||
|
|
||||||
if action == "encrypt":
|
def main():
|
||||||
encrypt_directory(source_dir, password, volume_size, encrypted_dir)
|
parser = argparse.ArgumentParser(
|
||||||
elif action == "decrypt":
|
description="基于密码的文件加密工具(AES-256-CFB + PBKDF2 + zlib 压缩 + 分卷存储)。"
|
||||||
decrypt_directory(encrypted_dir, password, decrypted_dir)
|
)
|
||||||
else:
|
sub = parser.add_subparsers(dest="action", required=True)
|
||||||
print("Invalid action. Use 'encrypt' or 'decrypt'.")
|
|
||||||
|
common = argparse.ArgumentParser(add_help=False)
|
||||||
|
common.add_argument("-p", "--password", help="加密/解密密码。省略时将交互式输入(不在命令行暴露密码)。")
|
||||||
|
common.add_argument("-v", "--volume-size", default="10MB",
|
||||||
|
help="分卷大小,支持 KB/MB/GB 后缀(默认 10MB)。")
|
||||||
|
|
||||||
|
enc = sub.add_parser("encrypt", parents=[common], help="加密目录")
|
||||||
|
enc.add_argument("source", help="待加密的源目录")
|
||||||
|
enc.add_argument("output", help="加密输出目录")
|
||||||
|
|
||||||
|
dec = sub.add_parser("decrypt", parents=[common], help="解密目录")
|
||||||
|
dec.add_argument("source", help="加密数据所在目录")
|
||||||
|
dec.add_argument("output", help="解密输出目录")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 密码获取:优先参数,否则交互输入
|
||||||
|
password = args.password if args.password else getpass.getpass("Enter password: ")
|
||||||
|
if not password:
|
||||||
|
print("[ERROR] Password cannot be empty.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
volume_size = parse_volume_size(args.volume_size)
|
||||||
|
except ValueError:
|
||||||
|
print(f"[ERROR] Invalid volume size: {args.volume_size}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.action == "encrypt":
|
||||||
|
encrypt_directory(args.source, password, volume_size, args.output)
|
||||||
|
elif args.action == "decrypt":
|
||||||
|
decrypt_directory(args.source, password, args.output)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
cryptography>=42.0.0
|
||||||
在新工单中引用
屏蔽一个用户