mini2fa 0.1.0__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.
mini2fa/config.py ADDED
@@ -0,0 +1,50 @@
1
+ """
2
+ 配置管理
3
+ """
4
+ import os
5
+ from pathlib import Path
6
+
7
+
8
+ # 数据目录(存储在用户主目录下的 .mini2fa)
9
+ DATA_DIR = Path.home() / '.mini2fa'
10
+ DB_PATH = DATA_DIR / 'mini2fa.db'
11
+ KEY_PATH = DATA_DIR / 'master.key'
12
+ BACKUP_DIR = DATA_DIR / 'backups'
13
+
14
+ # 安全参数
15
+ SALT_SIZE = 16 # 盐长度
16
+ KEY_SIZE = 32 # 密钥长度(256-bit)
17
+ ITERATIONS = 600_000 # PBKDF2 迭代次数(OWASP 推荐)
18
+ NONCE_SIZE = 12 # GCM Nonce 长度
19
+
20
+ # 默认 OTP 参数
21
+ DEFAULT_ALGORITHM = 'SHA1'
22
+ DEFAULT_DIGITS = 6
23
+ DEFAULT_PERIOD = 30
24
+ DEFAULT_CATEGORY = 'default'
25
+
26
+
27
+ def init_data_dir():
28
+ """初始化数据目录"""
29
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
30
+ BACKUP_DIR.mkdir(exist_ok=True)
31
+
32
+
33
+ def get_data_dir() -> Path:
34
+ """获取数据目录路径"""
35
+ return DATA_DIR
36
+
37
+
38
+ def get_db_path() -> Path:
39
+ """获取数据库文件路径"""
40
+ return DB_PATH
41
+
42
+
43
+ def get_key_path() -> Path:
44
+ """获取主密钥文件路径"""
45
+ return KEY_PATH
46
+
47
+
48
+ def get_backup_dir() -> Path:
49
+ """获取备份目录路径"""
50
+ return BACKUP_DIR
mini2fa/crypto.py ADDED
@@ -0,0 +1,328 @@
1
+ """
2
+ 加密解密模块
3
+
4
+ 使用 AES-256-GCM 认证加密,PBKDF2-SHA256 派生密钥
5
+ """
6
+ import os
7
+ import json
8
+ import base64
9
+ import binascii
10
+ from typing import Optional
11
+ from cryptography.hazmat.primitives import hashes
12
+ from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
13
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
14
+ from cryptography.hazmat.backends import default_backend
15
+
16
+ from .config import SALT_SIZE, KEY_SIZE, ITERATIONS, NONCE_SIZE
17
+
18
+
19
+ class CryptoManager:
20
+ """加密管理器"""
21
+
22
+ def __init__(self, master_key_path: str):
23
+ """
24
+ 初始化加密管理器
25
+
26
+ Args:
27
+ master_key_path: 主密钥文件路径
28
+ """
29
+ self.master_key_path = master_key_path
30
+ self.key = None # 派生密钥
31
+
32
+ def initialize(self, master_password: str, hint: str = '') -> bool:
33
+ """
34
+ 初始化或加载主密钥
35
+
36
+ Args:
37
+ master_password: 用户主密码
38
+ hint: 密保提示(首次设置时使用)
39
+
40
+ Returns:
41
+ 是否成功
42
+ """
43
+ if os.path.exists(self.master_key_path):
44
+ # 加载现有密钥
45
+ return self._load_key(master_password)
46
+ else:
47
+ # 首次使用,创建新密钥
48
+ return self._create_key(master_password, hint)
49
+
50
+ def _create_key(self, master_password: str, hint: str = '') -> bool:
51
+ """创建新的主密钥文件"""
52
+ salt = os.urandom(SALT_SIZE)
53
+
54
+ # 派生密钥
55
+ kdf = PBKDF2HMAC(
56
+ algorithm=hashes.SHA256(),
57
+ length=KEY_SIZE,
58
+ salt=salt,
59
+ iterations=ITERATIONS,
60
+ backend=default_backend()
61
+ )
62
+ derived_key = kdf.derive(master_password.encode('utf-8'))
63
+
64
+ # 生成主密钥(随机)
65
+ master_key = os.urandom(KEY_SIZE)
66
+
67
+ # 用派生密钥加密主密钥
68
+ aesgcm = AESGCM(derived_key)
69
+ nonce = os.urandom(NONCE_SIZE)
70
+ encrypted_master = aesgcm.encrypt(nonce, master_key, None)
71
+
72
+ # 保存到文件
73
+ data = {
74
+ 'salt': base64.b64encode(salt).decode(),
75
+ 'nonce': base64.b64encode(nonce).decode(),
76
+ 'encrypted_key': base64.b64encode(encrypted_master).decode(),
77
+ 'hint': hint, # 密保提示(明文存储)
78
+ 'version': 1
79
+ }
80
+
81
+ with open(self.master_key_path, 'w') as f:
82
+ json.dump(data, f, indent=2)
83
+
84
+ self.key = master_key
85
+ return True
86
+
87
+ def get_hint(self) -> str:
88
+ """获取密保提示"""
89
+ if not os.path.exists(self.master_key_path):
90
+ return ''
91
+
92
+ try:
93
+ with open(self.master_key_path, 'r') as f:
94
+ data = json.load(f)
95
+ return data.get('hint', '')
96
+ except Exception:
97
+ return ''
98
+
99
+ def get_key_data(self) -> dict:
100
+ """
101
+ 读取当前主密钥文件内容(导出备份用)
102
+
103
+ Returns:
104
+ 主密钥文件内容 dict;文件不存在或损坏时返回 None
105
+ """
106
+ if not os.path.exists(self.master_key_path):
107
+ return None
108
+
109
+ try:
110
+ with open(self.master_key_path, 'r') as f:
111
+ return json.load(f)
112
+ except Exception:
113
+ return None
114
+
115
+ def decrypt_with_key(self, key: bytes, ciphertext_b64: str) -> str:
116
+ """
117
+ 用指定密钥解密(不改变 self.key),供跨机导入重加密使用
118
+
119
+ Args:
120
+ key: 解密用的密钥(如备份的主密钥)
121
+ ciphertext_b64: Base64 编码的密文
122
+
123
+ Returns:
124
+ 明文
125
+
126
+ Raises:
127
+ ValueError: key 无效或密文损坏(base64 解码失败或 AES-GCM 认证失败)
128
+ """
129
+ try:
130
+ combined = base64.b64decode(ciphertext_b64)
131
+ nonce = combined[:NONCE_SIZE]
132
+ ciphertext = combined[NONCE_SIZE:]
133
+
134
+ aesgcm = AESGCM(key)
135
+ plaintext = aesgcm.decrypt(nonce, ciphertext, None)
136
+ return plaintext.decode('utf-8')
137
+ except Exception:
138
+ raise ValueError("密钥不匹配或密文已损坏")
139
+
140
+ def _load_key(self, master_password: str) -> bool:
141
+ """从文件加载主密钥"""
142
+ try:
143
+ with open(self.master_key_path, 'r') as f:
144
+ data = json.load(f)
145
+
146
+ salt = base64.b64decode(data['salt'])
147
+ nonce = base64.b64decode(data['nonce'])
148
+ encrypted_master = base64.b64decode(data['encrypted_key'])
149
+
150
+ # 派生密钥
151
+ kdf = PBKDF2HMAC(
152
+ algorithm=hashes.SHA256(),
153
+ length=KEY_SIZE,
154
+ salt=salt,
155
+ iterations=ITERATIONS,
156
+ backend=default_backend()
157
+ )
158
+ derived_key = kdf.derive(master_password.encode('utf-8'))
159
+
160
+ # 解密主密钥
161
+ aesgcm = AESGCM(derived_key)
162
+ self.key = aesgcm.decrypt(nonce, encrypted_master, None)
163
+ return True
164
+ except Exception:
165
+ self.key = None
166
+ return False
167
+
168
+ def load_external_key(self, key_data: dict, master_password: str) -> Optional[bytes]:
169
+ """
170
+ 用密码验证并解出备份文件中的主密钥(不改动 self.key)
171
+
172
+ Args:
173
+ key_data: 备份文件中嵌入的 master_key 内容 dict
174
+ master_password: 备份对应的主密码
175
+
176
+ Returns:
177
+ 解出的备份主密钥 bytes;密码错误返回 None
178
+
179
+ Raises:
180
+ ValueError: key_data 字段缺失或 Base64 数据损坏(不是密码问题)
181
+ """
182
+ try:
183
+ salt = base64.b64decode(key_data['salt'])
184
+ nonce = base64.b64decode(key_data['nonce'])
185
+ encrypted_master = base64.b64decode(key_data['encrypted_key'])
186
+ except (KeyError, TypeError, ValueError, binascii.Error):
187
+ raise ValueError("备份文件中的主密钥数据损坏,无法读取")
188
+
189
+ try:
190
+ kdf = PBKDF2HMAC(
191
+ algorithm=hashes.SHA256(),
192
+ length=KEY_SIZE,
193
+ salt=salt,
194
+ iterations=ITERATIONS,
195
+ backend=default_backend()
196
+ )
197
+ derived_key = kdf.derive(master_password.encode('utf-8'))
198
+
199
+ aesgcm = AESGCM(derived_key)
200
+ return aesgcm.decrypt(nonce, encrypted_master, None)
201
+ except Exception:
202
+ return None # 密码错误
203
+
204
+ def verify_password(self, password: str) -> bool:
205
+ """
206
+ 验证密码是否正确(不改变 self.key)
207
+
208
+ 与 initialize 不同:验证失败不会清空当前会话 key,
209
+ 供会话内验证旧密码等场景使用。
210
+ """
211
+ saved_key = self.key
212
+ result = self._load_key(password)
213
+ if not result:
214
+ self.key = saved_key # 失败时恢复,不污染会话
215
+ return result
216
+
217
+ def change_password(self, old_password: str, new_password: str, hint: str = '') -> bool:
218
+ """
219
+ 修改主密码:验证旧密码 → 用新密码重新加密 master_key
220
+
221
+ 数据库中的 secret_encrypted 不受影响(master_key 不变),
222
+ 修改后旧密码立即失效(新 salt + 新 nonce)。
223
+
224
+ Args:
225
+ old_password: 当前主密码
226
+ new_password: 新主密码
227
+ hint: 新密保提示(传 '' 会清空,调用方负责保留原 hint)
228
+
229
+ Returns:
230
+ 是否成功(旧密码错误返回 False)
231
+ """
232
+ # 1. 验证旧密码(解出当前 master_key;失败不污染会话 key)
233
+ if not self.verify_password(old_password):
234
+ return False
235
+ master_key = self.key
236
+
237
+ # 2. 用新密码派生新 KEK,重新加密 master_key
238
+ salt = os.urandom(SALT_SIZE)
239
+ kdf = PBKDF2HMAC(
240
+ algorithm=hashes.SHA256(),
241
+ length=KEY_SIZE,
242
+ salt=salt,
243
+ iterations=ITERATIONS,
244
+ backend=default_backend()
245
+ )
246
+ derived_key = kdf.derive(new_password.encode('utf-8'))
247
+
248
+ aesgcm = AESGCM(derived_key)
249
+ nonce = os.urandom(NONCE_SIZE)
250
+ encrypted_master = aesgcm.encrypt(nonce, master_key, None)
251
+
252
+ # 3. 写回 master.key
253
+ data = {
254
+ 'salt': base64.b64encode(salt).decode(),
255
+ 'nonce': base64.b64encode(nonce).decode(),
256
+ 'encrypted_key': base64.b64encode(encrypted_master).decode(),
257
+ 'hint': hint,
258
+ 'version': 1
259
+ }
260
+ with open(self.master_key_path, 'w') as f:
261
+ json.dump(data, f, indent=2)
262
+
263
+ # 4. self.key 保持 master_key,会话内继续可用
264
+ return True
265
+
266
+ def adopt_external_key(self, external_key: bytes, key_data: dict) -> None:
267
+ """
268
+ 跨机导入(空库):用备份主密钥替换本机主密钥
269
+
270
+ 写回本机 master.key 文件并更新内存中的 self.key,
271
+ 此后本机使用备份对应的主密码登录。
272
+
273
+ Args:
274
+ external_key: 备份主密钥(已由 load_external_key 解出)
275
+ key_data: 备份内嵌的 master_key 内容 dict
276
+ """
277
+ with open(self.master_key_path, 'w') as f:
278
+ json.dump(key_data, f, indent=2)
279
+ self.key = external_key
280
+
281
+ def encrypt(self, plaintext: str) -> str:
282
+ """
283
+ 加密字符串
284
+
285
+ Args:
286
+ plaintext: 明文
287
+
288
+ Returns:
289
+ Base64 编码的密文(含 nonce)
290
+ """
291
+ if not self.key:
292
+ raise RuntimeError("未初始化密钥")
293
+
294
+ aesgcm = AESGCM(self.key)
295
+ nonce = os.urandom(NONCE_SIZE)
296
+ ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
297
+
298
+ # 合并 nonce 和密文
299
+ combined = nonce + ciphertext
300
+ return base64.b64encode(combined).decode()
301
+
302
+ def decrypt(self, ciphertext_b64: str) -> str:
303
+ """
304
+ 解密字符串
305
+
306
+ Args:
307
+ ciphertext_b64: Base64 编码的密文
308
+
309
+ Returns:
310
+ 明文
311
+
312
+ Raises:
313
+ RuntimeError: 未初始化密钥
314
+ ValueError: base64 解码失败或 AES-GCM 认证失败(密文损坏/密钥不匹配)
315
+ """
316
+ if not self.key:
317
+ raise RuntimeError("未初始化密钥")
318
+
319
+ try:
320
+ combined = base64.b64decode(ciphertext_b64)
321
+ nonce = combined[:NONCE_SIZE]
322
+ ciphertext = combined[NONCE_SIZE:]
323
+
324
+ aesgcm = AESGCM(self.key)
325
+ plaintext = aesgcm.decrypt(nonce, ciphertext, None)
326
+ return plaintext.decode('utf-8')
327
+ except Exception:
328
+ raise ValueError("密文已损坏或密钥不匹配")
mini2fa/models.py ADDED
@@ -0,0 +1,33 @@
1
+ """
2
+ 数据模型定义
3
+ """
4
+ from dataclasses import dataclass
5
+ from typing import Optional
6
+
7
+
8
+ @dataclass
9
+ class Account:
10
+ """账号数据模型"""
11
+ id: int
12
+ issuer: str # 服务提供商(如 Google、GitHub)
13
+ account: str # 账号名(如 user@example.com)
14
+ secret_encrypted: str # 加密后的密钥
15
+ algorithm: str # 算法:SHA1/SHA256/SHA512
16
+ digits: int # 验证码位数
17
+ period: int # 时间步长(秒)
18
+ category: str # 分类
19
+ notes: str # 备注
20
+ created_at: str # 创建时间
21
+ updated_at: str # 更新时间
22
+
23
+
24
+ @dataclass
25
+ class OTPAccountInfo:
26
+ """从二维码解析出的 OTP 账号信息"""
27
+ issuer: str # 服务提供商
28
+ account: str # 账号名
29
+ secret: str # 明文密钥(Base32)
30
+ algorithm: str # 哈希算法
31
+ digits: int # 验证码位数
32
+ period: int # 时间步长
33
+ otp_type: str # OTP 类型:totp 或 hotp
mini2fa/scanner.py ADDED
@@ -0,0 +1,189 @@
1
+ """
2
+ 二维码扫描模块
3
+
4
+ 从图片文件中识别二维码并解析 OTP URI
5
+ """
6
+ import os
7
+ from typing import Optional
8
+ from urllib.parse import urlparse, parse_qs
9
+
10
+ from pyzbar.pyzbar import decode
11
+ from PIL import Image, ImageEnhance
12
+
13
+ from .models import OTPAccountInfo
14
+ from .config import DEFAULT_ALGORITHM, DEFAULT_DIGITS, DEFAULT_PERIOD
15
+
16
+
17
+ class UnsupportedOTPTypeError(Exception):
18
+ """OTP URI 类型不是 TOTP 时抛出(如 HOTP、STEAM 等)"""
19
+
20
+ def __init__(self, otp_type: str):
21
+ self.otp_type = otp_type
22
+ super().__init__(f"不支持的 OTP 类型: {otp_type}")
23
+
24
+
25
+ def preprocess_image(image: Image.Image) -> Image.Image:
26
+ """
27
+ 图片预处理,提高识别率
28
+
29
+ 1. 转换为灰度图
30
+ 2. 调整对比度
31
+ 3. 缩放到合理尺寸(如果太大)
32
+
33
+ Args:
34
+ image: 原始图片
35
+
36
+ Returns:
37
+ 预处理后的图片
38
+ """
39
+ # 转灰度
40
+ gray = image.convert('L')
41
+
42
+ # 增强对比度
43
+ enhancer = ImageEnhance.Contrast(gray)
44
+ enhanced = enhancer.enhance(2.0)
45
+
46
+ # 缩放(如果宽度超过1024)
47
+ if enhanced.width > 1024:
48
+ ratio = 1024 / enhanced.width
49
+ new_size = (1024, int(enhanced.height * ratio))
50
+ enhanced = enhanced.resize(new_size, Image.Resampling.LANCZOS)
51
+
52
+ return enhanced
53
+
54
+
55
+ def parse_otp_uri(uri: str) -> Optional[OTPAccountInfo]:
56
+ """
57
+ 解析 otpauth:// URI
58
+
59
+ Args:
60
+ uri: OTP URI 字符串
61
+
62
+ Returns:
63
+ OTPAccountInfo 对象,或 None(格式无效)
64
+
65
+ Raises:
66
+ UnsupportedOTPTypeError: URI 类型不是 totp(如 hotp、steam 等)
67
+
68
+ URI 格式示例:
69
+ otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=Example&algorithm=SHA1&digits=6&period=30
70
+ """
71
+ # 验证 URI 格式
72
+ if not uri.startswith('otpauth://'):
73
+ return None
74
+
75
+ try:
76
+ parsed = urlparse(uri)
77
+ params = parse_qs(parsed.query)
78
+
79
+ # 校验必要参数
80
+ if 'secret' not in params:
81
+ return None
82
+
83
+ # 校验 OTP 类型(仅支持 totp)
84
+ otp_type = parsed.netloc
85
+ if otp_type != 'totp':
86
+ raise UnsupportedOTPTypeError(otp_type)
87
+
88
+ # 提取标签(issuer:account 格式)
89
+ label = parsed.path.lstrip('/')
90
+ if ':' in label:
91
+ default_issuer, account = label.split(':', 1)
92
+ else:
93
+ default_issuer = ''
94
+ account = label
95
+
96
+ issuer = params.get('issuer', [default_issuer])[0] or default_issuer
97
+
98
+ return OTPAccountInfo(
99
+ issuer=issuer,
100
+ account=account,
101
+ secret=params['secret'][0],
102
+ algorithm=params.get('algorithm', [DEFAULT_ALGORITHM])[0],
103
+ digits=int(params.get('digits', [DEFAULT_DIGITS])[0]),
104
+ period=int(params.get('period', [DEFAULT_PERIOD])[0]),
105
+ otp_type=otp_type # 仅 'totp'
106
+ )
107
+ except UnsupportedOTPTypeError:
108
+ raise # 类型错误穿透,由调用方给出明确提示
109
+ except Exception:
110
+ return None
111
+
112
+
113
+ def _extract_otp_info(decoded_objects) -> Optional[OTPAccountInfo]:
114
+ """从 pyzbar 解码结果中提取第一个可解析的 OTP URI 信息
115
+
116
+ 若图片含多个二维码:优先返回 totp 类型;存在非 totp 类型时
117
+ 记下第一个遇到的类型,全部非 totp 才抛出。
118
+ """
119
+ first_unsupported = None
120
+ for obj in decoded_objects:
121
+ data = obj.data.decode('utf-8')
122
+ if not data.startswith('otpauth://'):
123
+ continue
124
+ try:
125
+ return parse_otp_uri(data)
126
+ except UnsupportedOTPTypeError as e:
127
+ if first_unsupported is None:
128
+ first_unsupported = e.otp_type
129
+ if first_unsupported is not None:
130
+ raise UnsupportedOTPTypeError(first_unsupported)
131
+ return None
132
+
133
+
134
+ def scan_qrcode(image_path: str) -> Optional[OTPAccountInfo]:
135
+ """
136
+ 从图片文件中扫描二维码并解析 OTP URI
137
+
138
+ Args:
139
+ image_path: 图片文件路径
140
+
141
+ Returns:
142
+ OTPAccountInfo 对象,或 None(识别失败)
143
+
144
+ Raises:
145
+ FileNotFoundError: 文件不存在
146
+ UnsupportedOTPTypeError: 二维码是 HOTP 等非 TOTP 类型
147
+ """
148
+ if not os.path.exists(image_path):
149
+ raise FileNotFoundError(f"图片文件不存在: {image_path}")
150
+
151
+ try:
152
+ image = Image.open(image_path)
153
+
154
+ # 尝试直接识别
155
+ decoded_objects = decode(image)
156
+
157
+ # 如果直接识别失败,尝试预处理后识别
158
+ if not decoded_objects:
159
+ processed = preprocess_image(image)
160
+ decoded_objects = decode(processed)
161
+
162
+ return _extract_otp_info(decoded_objects)
163
+
164
+ except UnsupportedOTPTypeError:
165
+ raise
166
+ except Exception as e:
167
+ raise ValueError(f"扫描失败: {e}")
168
+
169
+
170
+ def scan_qrcode_from_raw_data(data: bytes) -> Optional[OTPAccountInfo]:
171
+ """
172
+ 从原始数据中解析二维码
173
+
174
+ Args:
175
+ data: 原始字节数据
176
+
177
+ Returns:
178
+ OTPAccountInfo 对象,或 None
179
+
180
+ Raises:
181
+ UnsupportedOTPTypeError: 数据是 HOTP 等非 TOTP 类型
182
+ """
183
+ try:
184
+ decoded_objects = decode(data)
185
+ return _extract_otp_info(decoded_objects)
186
+ except UnsupportedOTPTypeError:
187
+ raise
188
+ except Exception:
189
+ return None