feat: 添加命令行接口与文档,改进错误处理

- 使用 argparse 提供 encrypt/decrypt 子命令,支持交互式密码与分卷大小解析
- 增加输入目录检查、密码错误提示和分卷识别容错
- 添加 .gitignore、LICENSE 和 README.md
- 将 CFB 模式迁移至 decrepit 模块并提取加密参数常量
这个提交包含在:
2026-08-20 16:16:45 +08:00
父节点 b0779c5b07
当前提交 9f00a2d36d
共修改 5 个文件,包含 235 行新增和 36 行删除
+10 -1
查看文件
@@ -1 +1,10 @@
.venv # Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# 加密/解密产物(运行生成)
encrypted/
decrypted/
+21
查看文件
@@ -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.
+96
查看文件
@@ -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)
+107 -35
查看文件
@@ -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
# 解密数据 # 解密数据
key = derive_key(password, salt) try:
decrypted_data = decrypt_data(key, encrypted_data) key = derive_key(password, salt)
decrypted_data = decrypt_data(key, encrypted_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
# 解压数据
decompressed_data = decompress_data(decrypted_data)
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()
+1
查看文件
@@ -0,0 +1 @@
cryptography>=42.0.0