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/storage.py ADDED
@@ -0,0 +1,414 @@
1
+ """
2
+ 数据存储模块
3
+
4
+ 使用 SQLite 存储账号数据,支持 JSON 导入导出
5
+ """
6
+ import sqlite3
7
+ import json
8
+ from datetime import datetime
9
+ from typing import List, Optional
10
+
11
+ from .models import Account
12
+ from .crypto import CryptoManager
13
+
14
+
15
+ class StorageCorruptedError(Exception):
16
+ """数据库文件损坏或无法读取时抛出"""
17
+
18
+
19
+ def _connect(db_path: str):
20
+ """
21
+ 连接数据库,损坏/锁定异常统一转 StorageCorruptedError
22
+ """
23
+ try:
24
+ return sqlite3.connect(db_path)
25
+ except sqlite3.Error as e:
26
+ raise StorageCorruptedError(f"数据库无法打开: {e}")
27
+
28
+
29
+ def _guard_errors(func):
30
+ """装饰器:把 sqlite3.Error(损坏/锁定/表异常)统一转 StorageCorruptedError"""
31
+ def wrapper(*args, **kwargs):
32
+ try:
33
+ return func(*args, **kwargs)
34
+ except sqlite3.Error as e:
35
+ raise StorageCorruptedError(f"数据库损坏或操作失败: {e}")
36
+ return wrapper
37
+
38
+
39
+ # 数据库表结构
40
+ CREATE_TABLE_SQL = '''
41
+ CREATE TABLE IF NOT EXISTS accounts (
42
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
43
+ issuer TEXT NOT NULL,
44
+ account TEXT NOT NULL,
45
+ secret_encrypted TEXT NOT NULL,
46
+ algorithm TEXT DEFAULT 'SHA1',
47
+ digits INTEGER DEFAULT 6,
48
+ period INTEGER DEFAULT 30,
49
+ category TEXT DEFAULT 'default',
50
+ notes TEXT,
51
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
52
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
53
+ UNIQUE(issuer, account)
54
+ );
55
+ '''
56
+
57
+ CREATE_INDEX1_SQL = '''
58
+ CREATE INDEX IF NOT EXISTS idx_accounts_issuer ON accounts(issuer);
59
+ '''
60
+
61
+ CREATE_INDEX2_SQL = '''
62
+ CREATE INDEX IF NOT EXISTS idx_accounts_category ON accounts(category);
63
+ '''
64
+
65
+ CREATE_TRIGGER_SQL = '''
66
+ CREATE TRIGGER IF NOT EXISTS update_accounts_timestamp
67
+ AFTER UPDATE ON accounts
68
+ BEGIN
69
+ UPDATE accounts SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
70
+ END;
71
+ '''
72
+
73
+
74
+ class StorageManager:
75
+ """存储管理器"""
76
+
77
+ def __init__(self, db_path: str, crypto_manager: CryptoManager):
78
+ """
79
+ Args:
80
+ db_path: 数据库文件路径
81
+ crypto_manager: 加密管理器实例
82
+ """
83
+ self.db_path = db_path
84
+ self.crypto = crypto_manager
85
+ self._init_db()
86
+
87
+ def _init_db(self):
88
+ """初始化数据库"""
89
+ try:
90
+ with _connect(self.db_path) as conn:
91
+ conn.execute(CREATE_TABLE_SQL)
92
+ conn.execute(CREATE_INDEX1_SQL)
93
+ conn.execute(CREATE_INDEX2_SQL)
94
+ conn.execute(CREATE_TRIGGER_SQL)
95
+ conn.commit()
96
+ except StorageCorruptedError:
97
+ raise
98
+ except sqlite3.Error as e:
99
+ raise StorageCorruptedError(f"数据库损坏或无法初始化: {e}")
100
+
101
+ @_guard_errors
102
+ def add_account(
103
+ self,
104
+ issuer: str,
105
+ account: str,
106
+ secret: str,
107
+ algorithm: str = 'SHA1',
108
+ digits: int = 6,
109
+ period: int = 30,
110
+ category: str = 'default',
111
+ notes: str = ''
112
+ ) -> int:
113
+ """
114
+ 添加新账号
115
+
116
+ Args:
117
+ issuer: 服务提供商
118
+ account: 账号名
119
+ secret: OTP 密钥(明文,将自动加密)
120
+ algorithm: 哈希算法
121
+ digits: 验证码位数
122
+ period: 时间步长
123
+ category: 分类
124
+ notes: 备注
125
+
126
+ Returns:
127
+ 新账号 ID
128
+
129
+ Raises:
130
+ ValueError: 账号已存在
131
+ """
132
+ # 加密密钥
133
+ encrypted_secret = self.crypto.encrypt(secret)
134
+
135
+ with _connect(self.db_path) as conn:
136
+ try:
137
+ cursor = conn.execute('''
138
+ INSERT INTO accounts
139
+ (issuer, account, secret_encrypted, algorithm, digits, period, category, notes)
140
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
141
+ ''', (issuer, account, encrypted_secret, algorithm, digits, period, category, notes))
142
+ conn.commit()
143
+ return cursor.lastrowid
144
+ except sqlite3.IntegrityError:
145
+ raise ValueError(f"账号已存在: {issuer}:{account}")
146
+
147
+ @_guard_errors
148
+ def get_account(self, account_id: int) -> Optional[Account]:
149
+ """获取单个账号"""
150
+ with _connect(self.db_path) as conn:
151
+ conn.row_factory = sqlite3.Row
152
+ row = conn.execute('SELECT * FROM accounts WHERE id = ?', (account_id,)).fetchone()
153
+ if row:
154
+ return Account(**dict(row))
155
+ return None
156
+
157
+ @_guard_errors
158
+ def get_all_accounts(self, category: str = None) -> List[Account]:
159
+ """获取所有账号"""
160
+ with _connect(self.db_path) as conn:
161
+ conn.row_factory = sqlite3.Row
162
+ if category:
163
+ rows = conn.execute(
164
+ 'SELECT * FROM accounts WHERE category = ? ORDER BY issuer, account',
165
+ (category,)
166
+ ).fetchall()
167
+ else:
168
+ rows = conn.execute(
169
+ 'SELECT * FROM accounts ORDER BY issuer, account'
170
+ ).fetchall()
171
+ return [Account(**dict(row)) for row in rows]
172
+
173
+ def get_secret(self, account_id: int) -> str:
174
+ """解密并获取密钥"""
175
+ account = self.get_account(account_id)
176
+ if not account:
177
+ raise ValueError(f"账号不存在: {account_id}")
178
+ return self.crypto.decrypt(account.secret_encrypted)
179
+
180
+ @_guard_errors
181
+ def find_by_identity(self, issuer: str, account: str) -> Optional[Account]:
182
+ """按 (issuer, account) 精确查找账号"""
183
+ with _connect(self.db_path) as conn:
184
+ conn.row_factory = sqlite3.Row
185
+ row = conn.execute(
186
+ 'SELECT * FROM accounts WHERE issuer = ? AND account = ?',
187
+ (issuer, account)
188
+ ).fetchone()
189
+ if row:
190
+ return Account(**dict(row))
191
+ return None
192
+
193
+ @_guard_errors
194
+ def overwrite_account(self, account_id: int, secret_encrypted: str,
195
+ algorithm: str, digits: int, period: int,
196
+ category: str, notes: str) -> bool:
197
+ """用备份数据整体覆盖账号(含密钥),供导入冲突时使用"""
198
+ with _connect(self.db_path) as conn:
199
+ cursor = conn.execute('''
200
+ UPDATE accounts SET
201
+ secret_encrypted = ?,
202
+ algorithm = ?,
203
+ digits = ?,
204
+ period = ?,
205
+ category = ?,
206
+ notes = ?
207
+ WHERE id = ?
208
+ ''', (secret_encrypted, algorithm, digits, period, category, notes, account_id))
209
+ conn.commit()
210
+ return cursor.rowcount > 0
211
+
212
+ @_guard_errors
213
+ def update_account(self, account_id: int, **kwargs) -> bool:
214
+ """更新账号信息"""
215
+ # 检查账号是否存在
216
+ if not self.get_account(account_id):
217
+ return False
218
+
219
+ allowed_fields = {'category', 'notes'}
220
+ updates = {k: v for k, v in kwargs.items() if k in allowed_fields}
221
+
222
+ if not updates:
223
+ return False
224
+
225
+ set_clause = ', '.join(f'{k} = ?' for k in updates.keys())
226
+ values = list(updates.values()) + [account_id]
227
+
228
+ with _connect(self.db_path) as conn:
229
+ conn.execute(f'UPDATE accounts SET {set_clause} WHERE id = ?', values)
230
+ conn.commit()
231
+ return True
232
+
233
+ @_guard_errors
234
+ def delete_account(self, account_id: int) -> bool:
235
+ """删除账号"""
236
+ with _connect(self.db_path) as conn:
237
+ cursor = conn.execute('DELETE FROM accounts WHERE id = ?', (account_id,))
238
+ conn.commit()
239
+ return cursor.rowcount > 0
240
+
241
+ def export_json(self, output_path: str) -> int:
242
+ """
243
+ 导出为 JSON 文件(密钥仍加密,内嵌主密钥文件以支持跨机器迁移)
244
+
245
+ Args:
246
+ output_path: 输出文件路径
247
+
248
+ Returns:
249
+ 导出的账号数量
250
+ """
251
+ accounts = self.get_all_accounts()
252
+
253
+ export_data = {
254
+ 'version': 1,
255
+ 'exported_at': datetime.now().isoformat(),
256
+ 'master_key': self.crypto.get_key_data(), # 内嵌主密钥文件,支持跨机恢复
257
+ 'accounts': [
258
+ {
259
+ 'issuer': a.issuer,
260
+ 'account': a.account,
261
+ 'secret_encrypted': a.secret_encrypted,
262
+ 'algorithm': a.algorithm,
263
+ 'digits': a.digits,
264
+ 'period': a.period,
265
+ 'category': a.category,
266
+ 'notes': a.notes
267
+ }
268
+ for a in accounts
269
+ ]
270
+ }
271
+
272
+ with open(output_path, 'w', encoding='utf-8') as f:
273
+ json.dump(export_data, f, indent=2, ensure_ascii=False)
274
+
275
+ return len(accounts)
276
+
277
+ def import_json(self, input_path: str, external_key: bytes = None,
278
+ decisions: dict = None) -> dict:
279
+ """
280
+ 从 JSON 文件导入(密钥仍加密)
281
+
282
+ Args:
283
+ input_path: 输入文件路径
284
+ external_key: 备份对应的主密钥(跨机导入时用外部 key 解密再重加密);
285
+ None 时用当前 self.key(同机导入)
286
+ decisions: 冲突决策映射 {(issuer, account): 'current' | 'backup'};
287
+ 'backup' 用备份整体覆盖,'current' 或缺失时保留当前(跳过)
288
+
289
+ Returns:
290
+ {'imported': 新增数, 'updated': 覆盖数,
291
+ 'conflict_skipped': 冲突保留当前数, 'damaged_skipped': 数据损坏跳过数}
292
+ """
293
+ with open(input_path, 'r', encoding='utf-8') as f:
294
+ data = json.load(f)
295
+
296
+ imported = 0
297
+ updated = 0
298
+ conflict_skipped = 0
299
+ damaged_skipped = 0
300
+ decisions = decisions or {}
301
+
302
+ for acc in data.get('accounts', []):
303
+ issuer = acc['issuer']
304
+ account = acc['account']
305
+
306
+ # 解密备份中的明文密钥
307
+ try:
308
+ if external_key is not None:
309
+ decrypted_secret = self.crypto.decrypt_with_key(external_key, acc['secret_encrypted'])
310
+ else:
311
+ decrypted_secret = self.crypto.decrypt(acc['secret_encrypted'])
312
+ except ValueError:
313
+ damaged_skipped += 1 # 密钥无法解密(损坏),跳过
314
+ continue
315
+
316
+ existing = self.find_by_identity(issuer, account)
317
+
318
+ if existing is None:
319
+ # 新增账号
320
+ self.add_account(
321
+ issuer=issuer,
322
+ account=account,
323
+ secret=decrypted_secret,
324
+ algorithm=acc.get('algorithm', 'SHA1'),
325
+ digits=acc.get('digits', 6),
326
+ period=acc.get('period', 30),
327
+ category=acc.get('category', 'default'),
328
+ notes=acc.get('notes', '')
329
+ )
330
+ imported += 1
331
+ elif decisions.get((issuer, account)) == 'backup':
332
+ # 用户选择用备份覆盖(整体覆盖,含密钥和全部字段)
333
+ new_secret = self.crypto.encrypt(decrypted_secret)
334
+ self.overwrite_account(
335
+ existing.id,
336
+ new_secret,
337
+ acc.get('algorithm', 'SHA1'),
338
+ acc.get('digits', 6),
339
+ acc.get('period', 30),
340
+ acc.get('category', 'default'),
341
+ acc.get('notes', '')
342
+ )
343
+ updated += 1
344
+ else:
345
+ # 用户选择保留当前(或无决策,默认跳过)
346
+ conflict_skipped += 1
347
+
348
+ return {
349
+ 'imported': imported,
350
+ 'updated': updated,
351
+ 'conflict_skipped': conflict_skipped,
352
+ 'damaged_skipped': damaged_skipped
353
+ }
354
+
355
+ def preview_import(self, input_path: str, external_key: bytes = None) -> dict:
356
+ """
357
+ 预览导入内容,不实际导入
358
+
359
+ Args:
360
+ input_path: 输入文件路径
361
+ external_key: 备份对应的主密钥(跨机导入时用于解密校验);
362
+ None 时用当前 self.key(同机导入)
363
+
364
+ Returns:
365
+ {
366
+ 'total': 3,
367
+ 'to_import': [{'issuer': 'Google', ...}, ...],
368
+ 'to_skip': [{'issuer': 'GitHub', ...}, ...], # 已存在账号
369
+ 'damaged': [{'issuer': 'X', ...}, ...] # 无法解密的损坏条目
370
+ }
371
+ """
372
+ # 获取现有账号集合
373
+ existing = self.get_all_accounts()
374
+ existing_keys = {(acc.issuer, acc.account) for acc in existing}
375
+
376
+ with open(input_path, 'r', encoding='utf-8') as f:
377
+ data = json.load(f)
378
+
379
+ # 校验版本号
380
+ version = data.get('version', 0)
381
+ if version < 1:
382
+ raise ValueError("不支持的备份文件格式")
383
+
384
+ to_import = []
385
+ to_skip = []
386
+ damaged = []
387
+
388
+ for acc in data.get('accounts', []):
389
+ # 校验必要字段
390
+ if 'issuer' not in acc or 'account' not in acc or 'secret_encrypted' not in acc:
391
+ raise ValueError("备份文件中存在字段不完整的账号数据")
392
+
393
+ # 校验该条目能否解密(无法解密的归入 damaged,不会真正导入)
394
+ try:
395
+ if external_key is not None:
396
+ self.crypto.decrypt_with_key(external_key, acc['secret_encrypted'])
397
+ else:
398
+ self.crypto.decrypt(acc['secret_encrypted'])
399
+ except ValueError:
400
+ damaged.append(acc)
401
+ continue
402
+
403
+ key = (acc['issuer'], acc['account'])
404
+ if key in existing_keys:
405
+ to_skip.append(acc)
406
+ else:
407
+ to_import.append(acc)
408
+
409
+ return {
410
+ 'total': len(data.get('accounts', [])),
411
+ 'to_import': to_import,
412
+ 'to_skip': to_skip,
413
+ 'damaged': damaged
414
+ }
mini2fa/totp.py ADDED
@@ -0,0 +1,119 @@
1
+ """
2
+ TOTP (Time-based One-Time Password) 算法实现
3
+
4
+ 基于 RFC 6238 标准
5
+ """
6
+ import hmac
7
+ import hashlib
8
+ import struct
9
+ import time
10
+ import base64
11
+
12
+
13
+ def generate_totp(
14
+ secret: str,
15
+ algorithm: str = 'SHA1',
16
+ digits: int = 6,
17
+ period: int = 30
18
+ ) -> str:
19
+ """
20
+ 生成 TOTP 验证码
21
+
22
+ Args:
23
+ secret: Base32 编码的密钥
24
+ algorithm: 哈希算法 (SHA1/SHA256/SHA512)
25
+ digits: 验证码位数
26
+ period: 时间步长(秒)
27
+
28
+ Returns:
29
+ 验证码字符串(如 '123456')
30
+ """
31
+ # Base32 解码
32
+ key = base64.b32decode(secret, casefold=True)
33
+
34
+ # 计算时间步数
35
+ counter = int(time.time()) // period
36
+
37
+ # 打包为 8 字节大端序
38
+ msg = struct.pack('>Q', counter)
39
+
40
+ # 选择哈希算法
41
+ hash_func = {
42
+ 'SHA1': hashlib.sha1,
43
+ 'SHA256': hashlib.sha256,
44
+ 'SHA512': hashlib.sha512
45
+ }[algorithm]
46
+
47
+ # HMAC 计算
48
+ hmac_result = hmac.new(key, msg, hash_func).digest()
49
+
50
+ # 动态截断
51
+ offset = hmac_result[-1] & 0x0F
52
+ truncated = struct.unpack('>I', hmac_result[offset:offset+4])[0]
53
+ truncated &= 0x7FFFFFFF
54
+
55
+ # 取模得到验证码
56
+ code = truncated % (10 ** digits)
57
+
58
+ # 左侧补零
59
+ return str(code).zfill(digits)
60
+
61
+
62
+ def get_remaining_seconds(period: int = 30) -> int:
63
+ """
64
+ 获取当前周期剩余秒数
65
+
66
+ Args:
67
+ period: 时间步长(秒)
68
+
69
+ Returns:
70
+ 剩余秒数(1-30)
71
+ """
72
+ return period - int(time.time()) % period
73
+
74
+
75
+ def verify_totp(
76
+ code: str,
77
+ secret: str,
78
+ algorithm: str = 'SHA1',
79
+ digits: int = 6,
80
+ period: int = 30,
81
+ window: int = 1
82
+ ) -> bool:
83
+ """
84
+ 验证 TOTP 验证码
85
+
86
+ Args:
87
+ code: 用户输入的验证码
88
+ secret: Base32 编码的密钥
89
+ algorithm: 哈希算法
90
+ digits: 验证码位数
91
+ period: 时间步长
92
+ window: 验证窗口(允许前/后 N 个周期)
93
+
94
+ Returns:
95
+ 验证是否成功
96
+ """
97
+ current_counter = int(time.time()) // period
98
+ key = base64.b32decode(secret, casefold=True)
99
+ hash_func = {
100
+ 'SHA1': hashlib.sha1,
101
+ 'SHA256': hashlib.sha256,
102
+ 'SHA512': hashlib.sha512
103
+ }[algorithm]
104
+
105
+ for offset in range(-window, window + 1):
106
+ counter = current_counter + offset
107
+ msg = struct.pack('>Q', counter)
108
+
109
+ hmac_result = hmac.new(key, msg, hash_func).digest()
110
+
111
+ offset_byte = hmac_result[-1] & 0x0F
112
+ truncated = struct.unpack('>I', hmac_result[offset_byte:offset_byte+4])[0]
113
+ truncated &= 0x7FFFFFFF
114
+ expected = str(truncated % (10 ** digits)).zfill(digits)
115
+
116
+ if code == expected:
117
+ return True
118
+
119
+ return False
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: mini2fa
3
+ Version: 0.1.0
4
+ Summary: 安全的本地 TOTP 双因素认证管理工具
5
+ License-Expression: GPL-3.0-or-later
6
+ Project-URL: Homepage, https://github.com/Duroxi/Mini2FA
7
+ Project-URL: Repository, https://github.com/Duroxi/Mini2FA
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: pyzbar>=0.1.9
12
+ Requires-Dist: Pillow>=10.0.0
13
+ Requires-Dist: cryptography>=41.0.0
14
+ Requires-Dist: wcwidth>=0.2.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest; extra == "dev"
17
+ Requires-Dist: pytest-cov; extra == "dev"
18
+ Requires-Dist: qrcode[pil]; extra == "dev"
19
+ Dynamic: license-file
20
+
21
+ # Mini2FA 🔑
22
+
23
+ 安全的本地 TOTP 双因素认证管理工具 — Python 包。
24
+
25
+ ## 安装
26
+
27
+ ```bash
28
+ pip install mini2fa
29
+ ```
30
+
31
+ ## 使用
32
+
33
+ ### 命令行
34
+
35
+ ```bash
36
+ mini2fa
37
+ ```
38
+
39
+ ### 作为库
40
+
41
+ ```python
42
+ from mini2fa import generate_totp, verify_totp
43
+
44
+ code = generate_totp('JBSWY3DPEHPK3PXP')
45
+ print(code) # 123456
46
+ ```
47
+
48
+ ## 功能
49
+
50
+ - 📱 从图片扫码添加 2FA 账号(仅支持 TOTP)
51
+ - 🔑 生成符合 RFC 6238 的 TOTP 验证码
52
+ - 🔐 AES-256-GCM 加密存储(PBKDF2 密钥派生)
53
+ - 📂 分类分组显示 + 关键字搜索
54
+ - 💾 JSON 导入/导出备份(内嵌主密钥,支持跨机器迁移)
55
+ - 🔒 修改主密码、密保提示
56
+ - 🖥️ 跨平台(Windows/Linux/macOS)
57
+
58
+ ## 项目
59
+
60
+ - GitHub: https://github.com/Duroxi/Mini2FA
61
+ - License: MIT
@@ -0,0 +1,15 @@
1
+ mini2fa/__init__.py,sha256=tTaE4A9dYtiqxeEh-PRXU5jNRKbbG37KqenD2Bh3ziw,388
2
+ mini2fa/__main__.py,sha256=XiH2CDCAVtTjBxbnGGkT8kWQHMAdGpAxVHTdKtB2P0U,61
3
+ mini2fa/_cli.py,sha256=LXtg0zkx1gHLRtkXiTT5TzxnrT-WwpCaHzon_eG_89c,27744
4
+ mini2fa/config.py,sha256=l56s4BQ-4b8nBvZhqXNTg37N5n6__qFQOGRjNFn65Ok,1071
5
+ mini2fa/crypto.py,sha256=1IwUlrblcGYHnrk9ovTInQ9OBo4VY_TVsQI2F-psg2E,10685
6
+ mini2fa/models.py,sha256=oUKQjLj3-u8DmvwqA_2QLUaRFpC4iqM2pFeD6yzwnM0,1043
7
+ mini2fa/scanner.py,sha256=2YmOrAZLXw_7Mifsx3Wynq0xifRQxc_ZcLL1O-nGuKc,5277
8
+ mini2fa/storage.py,sha256=juBYBRVcVwMoH-3fBfZ6D2ue7rYVMCwCOssdsIuXnrc,14700
9
+ mini2fa/totp.py,sha256=jmUql05IqbziWtBrNDtHxF7hENYGsEUS9l-_j0gsbsE,2705
10
+ mini2fa-0.1.0.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
11
+ mini2fa-0.1.0.dist-info/METADATA,sha256=XDG1J-KtVe01lCYDQfUauOZI_8agWfg8-j5i8rECwwo,1467
12
+ mini2fa-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
13
+ mini2fa-0.1.0.dist-info/entry_points.txt,sha256=AE6A8UiTiFfNfiR6Sy7qpYAM_ECHxPSpEOtKo2QYADw,46
14
+ mini2fa-0.1.0.dist-info/top_level.txt,sha256=SYmyRSkg9CICPgiarVZblgiknAwXztTA6UlbOl0NFTY,8
15
+ mini2fa-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mini2fa = mini2fa._cli:main