diff --git a/.gitignore b/.gitignore index 1d17dae..3077bbe 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,10 @@ -.venv +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# 加密/解密产物(运行生成) +encrypted/ +decrypted/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..46d9ba3 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f6a192d --- /dev/null +++ b/README.md @@ -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) diff --git a/main.py b/main.py index f51944a..48f694e 100644 --- a/main.py +++ b/main.py @@ -1,24 +1,32 @@ import os +import sys import zlib +import argparse +import getpass from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC 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 +SALT_SIZE = 16 +IV_SIZE = 16 +KEY_SIZE = 32 +PBKDF2_ITERATIONS = 100_000 # 辅助函数:使用AES加密数据 def encrypt_data(key, data): - iv = os.urandom(16) # 生成随机的初始化向量(IV) - cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend()) + iv = os.urandom(IV_SIZE) # 生成随机的初始化向量(IV) + cipher = Cipher(algorithms.AES(key), CFB(iv), backend=default_backend()) encryptor = cipher.encryptor() encrypted_data = iv + encryptor.update(data) + encryptor.finalize() return encrypted_data # 辅助函数:使用AES解密数据 def decrypt_data(key, data): - iv = data[:16] # 提取初始化向量(IV) - encrypted_content = data[16:] - cipher = Cipher(algorithms.AES(key), modes.CFB(iv), backend=default_backend()) + iv = data[:IV_SIZE] # 提取初始化向量(IV) + encrypted_content = data[IV_SIZE:] + cipher = Cipher(algorithms.AES(key), CFB(iv), backend=default_backend()) decryptor = cipher.decryptor() decrypted_data = decryptor.update(encrypted_content) + decryptor.finalize() return decrypted_data @@ -27,9 +35,9 @@ def decrypt_data(key, data): def derive_key(password, salt): kdf = PBKDF2HMAC( algorithm=SHA256(), - length=32, + length=KEY_SIZE, salt=salt, - iterations=100000, + iterations=PBKDF2_ITERATIONS, backend=default_backend() ) return kdf.derive(password.encode()) @@ -52,7 +60,7 @@ def encrypt_file(file_path, password, volume_size, output_dir): compressed_data = compress_data(data) # 加密压缩后的内容 - salt = os.urandom(16) + salt = os.urandom(SALT_SIZE) key = derive_key(password, salt) 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: 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): # 读取所有分卷或单个加密文件 - 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: + # 分卷文件名形如 .part1 / .part2 ...;单文件为 .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) # 确保按顺序读取 - print(f"Parts found: {parts}") + encrypted_data = b"" salt = None for i, part in enumerate(parts): with open(part, 'rb') as part_file: if i == 0: - salt = part_file.read(16) + # 首个分卷(或单文件 .enc)的前 SALT_SIZE 字节为 salt + salt = part_file.read(SALT_SIZE) encrypted_data += part_file.read() - if salt is None: - raise ValueError("Salt not found in the first part.") + if not salt or len(salt) != SALT_SIZE: + print(f"[ERROR] Invalid salt in first part of: {encrypted_dir}") + return # 解密数据 - key = derive_key(password, salt) - decrypted_data = decrypt_data(key, encrypted_data) + try: + 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)) with open(output_file, 'wb') as f: 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): + 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 file in files: 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): + 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 dir in dirs: - encrypted_dir = os.path.join(root, dir) + for d in dirs: + encrypted_dir = os.path.join(root, d) decrypt_file(encrypted_dir, password, output_dir) -if __name__ == "__main__": - action = "encrypt" # 或 "decrypt" - # action = "decrypt" # 或 "decrypt" - password = "123456" - volume_size = 10 * 1024 * 1024 # 每个分卷大小(10 MB) - source_dir = "./data" # 原始数据文件夹 - encrypted_dir = "./encrypted" # 加密文件夹 - decrypted_dir = "./decrypted" # 解密文件夹 +def parse_volume_size(text): + """解析分卷大小,支持 KB/MB/GB 后缀,如 10MB。""" + units = {"KB": 1024, "MB": 1024**2, "GB": 1024**3} + text = text.strip().upper() + for suffix, factor in units.items(): + if text.endswith(suffix): + return int(text[:-len(suffix)]) * factor + return int(text) # 纯数字视为字节 - if action == "encrypt": - encrypt_directory(source_dir, password, volume_size, encrypted_dir) - elif action == "decrypt": - decrypt_directory(encrypted_dir, password, decrypted_dir) - else: - print("Invalid action. Use 'encrypt' or 'decrypt'.") \ No newline at end of file +def main(): + parser = argparse.ArgumentParser( + description="基于密码的文件加密工具(AES-256-CFB + PBKDF2 + zlib 压缩 + 分卷存储)。" + ) + sub = parser.add_subparsers(dest="action", required=True) + + 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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e30c06f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +cryptography>=42.0.0