jkey 0.1.2__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.
- jkey/2fa/__init__.py +0 -0
- jkey/2fa/core.py +124 -0
- jkey/2fa/qr.py +65 -0
- jkey/__init__.py +0 -0
- jkey/__main__.py +4 -0
- jkey/aes.py +279 -0
- jkey/cli.py +154 -0
- jkey/pm/__init__.py +0 -0
- jkey/pm/core.py +86 -0
- jkey/pm/gen.py +33 -0
- jkey/pv/__init__.py +0 -0
- jkey/pv/core.py +290 -0
- jkey/pv/export.py +147 -0
- jkey-0.1.2.dist-info/METADATA +105 -0
- jkey-0.1.2.dist-info/RECORD +18 -0
- jkey-0.1.2.dist-info/WHEEL +5 -0
- jkey-0.1.2.dist-info/entry_points.txt +2 -0
- jkey-0.1.2.dist-info/top_level.txt +1 -0
jkey/2fa/__init__.py
ADDED
|
File without changes
|
jkey/2fa/core.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import os
|
|
5
|
+
import struct
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from jkey.pv.core import load_recovery, load_totp, save_recovery, save_totp
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _hotp(secret: bytes, counter: int, digits: int = 6) -> str:
|
|
12
|
+
msg = struct.pack(">Q", counter)
|
|
13
|
+
h = hmac.new(secret, msg, hashlib.sha1).digest()
|
|
14
|
+
offset = h[-1] & 0xf
|
|
15
|
+
truncated = struct.unpack(">I", h[offset:offset+4])[0] & 0x7fffffff
|
|
16
|
+
return str(truncated % (10 ** digits)).zfill(digits)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _b32_decode(s: str) -> bytes:
|
|
20
|
+
s = s.upper().replace(" ", "")
|
|
21
|
+
remainder = len(s) % 8
|
|
22
|
+
if remainder:
|
|
23
|
+
s += "=" * (8 - remainder)
|
|
24
|
+
return base64.b32decode(s)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validate_b32_secret(s: str) -> bool:
|
|
28
|
+
try:
|
|
29
|
+
_b32_decode(s)
|
|
30
|
+
return True
|
|
31
|
+
except Exception:
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def totp(secret_key: str, digits: int = 6, interval: int = 30) -> str:
|
|
36
|
+
secret = _b32_decode(secret_key)
|
|
37
|
+
counter = int(time.time()) // interval
|
|
38
|
+
return _hotp(secret, counter, digits)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _import_recovery_file(account: str, recovery_path: str | None):
|
|
42
|
+
if not recovery_path:
|
|
43
|
+
return
|
|
44
|
+
if not os.path.exists(recovery_path):
|
|
45
|
+
print(f"Warning: Recovery file not found: {recovery_path}")
|
|
46
|
+
return
|
|
47
|
+
try:
|
|
48
|
+
with open(recovery_path, "r", encoding="utf-8") as f:
|
|
49
|
+
codes = [line.strip() for line in f if line.strip()]
|
|
50
|
+
if codes:
|
|
51
|
+
data = load_recovery()
|
|
52
|
+
if data is None:
|
|
53
|
+
return
|
|
54
|
+
data[account] = codes
|
|
55
|
+
save_recovery(data)
|
|
56
|
+
print(f"Imported {len(codes)} recovery codes for {account}")
|
|
57
|
+
except OSError as e:
|
|
58
|
+
print(f"Warning: Could not read recovery file: {e}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def list_accounts(keyword: str | None = None):
|
|
62
|
+
data = load_totp()
|
|
63
|
+
if data is None:
|
|
64
|
+
return
|
|
65
|
+
if not data:
|
|
66
|
+
print("No 2FA accounts found.")
|
|
67
|
+
return
|
|
68
|
+
keys = sorted(data.keys())
|
|
69
|
+
if keyword:
|
|
70
|
+
keys = [k for k in keys if keyword.lower() in k.lower()]
|
|
71
|
+
if not keys:
|
|
72
|
+
print(f"No accounts matching '{keyword}'.")
|
|
73
|
+
return
|
|
74
|
+
for acc_id in keys:
|
|
75
|
+
secret = data[acc_id]
|
|
76
|
+
try:
|
|
77
|
+
print(f"{acc_id}: {totp(secret)}")
|
|
78
|
+
except Exception as e:
|
|
79
|
+
print(f"Error processing '{acc_id}': {e}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def show_code(account: str):
|
|
83
|
+
data = load_totp()
|
|
84
|
+
if data is None:
|
|
85
|
+
return
|
|
86
|
+
if account not in data:
|
|
87
|
+
print(f"Error: Account '{account}' not found.")
|
|
88
|
+
return
|
|
89
|
+
secret = data[account]
|
|
90
|
+
try:
|
|
91
|
+
print(f"{account}: {totp(secret)}")
|
|
92
|
+
except Exception as e:
|
|
93
|
+
print(f"Error processing '{account}': {e}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def add_account(name: str, secret: str, recovery_path: str | None = None):
|
|
97
|
+
if not _validate_b32_secret(secret):
|
|
98
|
+
print(f"Error: Invalid base32 secret for '{name}'.")
|
|
99
|
+
return
|
|
100
|
+
data = load_totp()
|
|
101
|
+
if data is None:
|
|
102
|
+
return
|
|
103
|
+
data[name] = secret
|
|
104
|
+
save_totp(data)
|
|
105
|
+
print(f"Added 2FA account: {name}")
|
|
106
|
+
_import_recovery_file(name, recovery_path)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def remove_account(account: str):
|
|
110
|
+
data = load_totp()
|
|
111
|
+
if data is None:
|
|
112
|
+
return
|
|
113
|
+
if account not in data:
|
|
114
|
+
print(f"Error: Account '{account}' not found.")
|
|
115
|
+
return
|
|
116
|
+
del data[account]
|
|
117
|
+
save_totp(data)
|
|
118
|
+
|
|
119
|
+
rc = load_recovery()
|
|
120
|
+
if rc and account in rc:
|
|
121
|
+
del rc[account]
|
|
122
|
+
save_recovery(rc)
|
|
123
|
+
|
|
124
|
+
print(f"Removed 2FA account: {account}")
|
jkey/2fa/qr.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import os
|
|
3
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
4
|
+
|
|
5
|
+
import cv2
|
|
6
|
+
|
|
7
|
+
from jkey.pv.core import load_totp, save_qr_image, save_totp
|
|
8
|
+
|
|
9
|
+
_core = importlib.import_module("jkey.2fa.core")
|
|
10
|
+
_import_recovery_file = _core._import_recovery_file
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def scan_and_add(image_path: str, recovery_path: str | None = None):
|
|
14
|
+
if not os.path.exists(image_path):
|
|
15
|
+
print(f"Error: File not found: {image_path}")
|
|
16
|
+
return
|
|
17
|
+
|
|
18
|
+
img = cv2.imread(image_path)
|
|
19
|
+
if img is None:
|
|
20
|
+
print(f"Error: Could not read image: {image_path}")
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
detector = cv2.QRCodeDetector()
|
|
24
|
+
data, _, _ = detector.detectAndDecode(img)
|
|
25
|
+
if not data:
|
|
26
|
+
print("Error: No QR code found in the image.")
|
|
27
|
+
return
|
|
28
|
+
|
|
29
|
+
parsed = urlparse(data)
|
|
30
|
+
if parsed.scheme != "otpauth":
|
|
31
|
+
print(f"Error: Not a valid otpauth:// URL: {data}")
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
params = parse_qs(parsed.query)
|
|
35
|
+
secret = params.get("secret", [None])[0]
|
|
36
|
+
if not secret:
|
|
37
|
+
print("Error: No secret found in QR code.")
|
|
38
|
+
return
|
|
39
|
+
|
|
40
|
+
path = unquote(parsed.path).lstrip("/")
|
|
41
|
+
issuer = params.get("issuer", [None])[0]
|
|
42
|
+
|
|
43
|
+
if issuer and path.startswith(issuer + ":"):
|
|
44
|
+
name = path[len(issuer) + 1:]
|
|
45
|
+
elif issuer and ":" in path:
|
|
46
|
+
name = path
|
|
47
|
+
else:
|
|
48
|
+
name = path
|
|
49
|
+
|
|
50
|
+
if not name:
|
|
51
|
+
name = issuer or "unknown"
|
|
52
|
+
|
|
53
|
+
data = load_totp()
|
|
54
|
+
if data is None:
|
|
55
|
+
return
|
|
56
|
+
data[name] = secret
|
|
57
|
+
save_totp(data)
|
|
58
|
+
print(f"Added 2FA account: {name}")
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
save_qr_image(name, cv2.imencode(".jpg", img)[1].tobytes())
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
_import_recovery_file(name, recovery_path)
|
jkey/__init__.py
ADDED
|
File without changes
|
jkey/__main__.py
ADDED
jkey/aes.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import hmac
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
SBOX = [
|
|
9
|
+
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b,
|
|
10
|
+
0xfe, 0xd7, 0xab, 0x76, 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0,
|
|
11
|
+
0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0, 0xb7, 0xfd, 0x93, 0x26,
|
|
12
|
+
0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
|
|
13
|
+
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2,
|
|
14
|
+
0xeb, 0x27, 0xb2, 0x75, 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0,
|
|
15
|
+
0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84, 0x53, 0xd1, 0x00, 0xed,
|
|
16
|
+
0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
|
|
17
|
+
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f,
|
|
18
|
+
0x50, 0x3c, 0x9f, 0xa8, 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5,
|
|
19
|
+
0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2, 0xcd, 0x0c, 0x13, 0xec,
|
|
20
|
+
0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
|
|
21
|
+
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14,
|
|
22
|
+
0xde, 0x5e, 0x0b, 0xdb, 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c,
|
|
23
|
+
0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79, 0xe7, 0xc8, 0x37, 0x6d,
|
|
24
|
+
0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
|
|
25
|
+
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f,
|
|
26
|
+
0x4b, 0xbd, 0x8b, 0x8a, 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e,
|
|
27
|
+
0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e, 0xe1, 0xf8, 0x98, 0x11,
|
|
28
|
+
0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
|
|
29
|
+
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f,
|
|
30
|
+
0xb0, 0x54, 0xbb, 0x16,
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
INV_SBOX = [0] * 256
|
|
34
|
+
for _i, _v in enumerate(SBOX):
|
|
35
|
+
INV_SBOX[_v] = _i
|
|
36
|
+
|
|
37
|
+
RCON = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _xtime(a):
|
|
41
|
+
return ((a << 1) ^ 0x11b) & 0xff if a & 0x80 else (a << 1) & 0xff
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _gf_mul(a, b):
|
|
45
|
+
result = 0
|
|
46
|
+
for _ in range(8):
|
|
47
|
+
if b & 1:
|
|
48
|
+
result ^= a
|
|
49
|
+
a = _xtime(a)
|
|
50
|
+
b >>= 1
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _sub_word(word):
|
|
55
|
+
return (
|
|
56
|
+
(SBOX[(word >> 24) & 0xff] << 24)
|
|
57
|
+
| (SBOX[(word >> 16) & 0xff] << 16)
|
|
58
|
+
| (SBOX[(word >> 8) & 0xff] << 8)
|
|
59
|
+
| SBOX[word & 0xff]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _rot_word(word):
|
|
64
|
+
return ((word << 8) | (word >> 24)) & 0xffffffff
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _key_expansion(key: bytes) -> list:
|
|
68
|
+
nr = 14
|
|
69
|
+
nk = 8
|
|
70
|
+
nb = 4
|
|
71
|
+
nw = nb * (nr + 1)
|
|
72
|
+
w = []
|
|
73
|
+
for i in range(nk):
|
|
74
|
+
w.append(int.from_bytes(key[4*i:4*i+4], 'big'))
|
|
75
|
+
for i in range(nk, nw):
|
|
76
|
+
temp = w[i - 1]
|
|
77
|
+
if i % nk == 0:
|
|
78
|
+
temp = _sub_word(_rot_word(temp)) ^ (RCON[i // nk] << 24)
|
|
79
|
+
elif i % nk == 4:
|
|
80
|
+
temp = _sub_word(temp)
|
|
81
|
+
w.append(w[i - nk] ^ temp)
|
|
82
|
+
return w
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _bytes_to_state(b: bytes):
|
|
86
|
+
return [list(b[i:i+4]) for i in range(0, 16, 4)]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _state_to_bytes(state):
|
|
90
|
+
result = bytearray()
|
|
91
|
+
for row in state:
|
|
92
|
+
result.extend(row)
|
|
93
|
+
return bytes(result)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _add_round_key(state, rk):
|
|
97
|
+
rk_bytes = []
|
|
98
|
+
for word in rk:
|
|
99
|
+
rk_bytes.extend([(word >> 24) & 0xff, (word >> 16) & 0xff, (word >> 8) & 0xff, word & 0xff])
|
|
100
|
+
for i in range(4):
|
|
101
|
+
for j in range(4):
|
|
102
|
+
state[j][i] ^= rk_bytes[i * 4 + j]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _sub_bytes(state):
|
|
106
|
+
for i in range(4):
|
|
107
|
+
for j in range(4):
|
|
108
|
+
state[i][j] = SBOX[state[i][j]]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _inv_sub_bytes(state):
|
|
112
|
+
for i in range(4):
|
|
113
|
+
for j in range(4):
|
|
114
|
+
state[i][j] = INV_SBOX[state[i][j]]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _shift_rows(state):
|
|
118
|
+
state[1][0], state[1][1], state[1][2], state[1][3] = state[1][1], state[1][2], state[1][3], state[1][0]
|
|
119
|
+
state[2][0], state[2][1], state[2][2], state[2][3] = state[2][2], state[2][3], state[2][0], state[2][1]
|
|
120
|
+
state[3][0], state[3][1], state[3][2], state[3][3] = state[3][3], state[3][0], state[3][1], state[3][2]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _inv_shift_rows(state):
|
|
124
|
+
state[1][0], state[1][1], state[1][2], state[1][3] = state[1][3], state[1][0], state[1][1], state[1][2]
|
|
125
|
+
state[2][0], state[2][1], state[2][2], state[2][3] = state[2][2], state[2][3], state[2][0], state[2][1]
|
|
126
|
+
state[3][0], state[3][1], state[3][2], state[3][3] = state[3][1], state[3][2], state[3][3], state[3][0]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _mix_columns(state):
|
|
130
|
+
for i in range(4):
|
|
131
|
+
a = [state[j][i] for j in range(4)]
|
|
132
|
+
state[0][i] = _gf_mul(2, a[0]) ^ _gf_mul(3, a[1]) ^ a[2] ^ a[3]
|
|
133
|
+
state[1][i] = a[0] ^ _gf_mul(2, a[1]) ^ _gf_mul(3, a[2]) ^ a[3]
|
|
134
|
+
state[2][i] = a[0] ^ a[1] ^ _gf_mul(2, a[2]) ^ _gf_mul(3, a[3])
|
|
135
|
+
state[3][i] = _gf_mul(3, a[0]) ^ a[1] ^ a[2] ^ _gf_mul(2, a[3])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _inv_mix_columns(state):
|
|
139
|
+
for i in range(4):
|
|
140
|
+
a = [state[j][i] for j in range(4)]
|
|
141
|
+
state[0][i] = _gf_mul(14, a[0]) ^ _gf_mul(11, a[1]) ^ _gf_mul(13, a[2]) ^ _gf_mul(9, a[3])
|
|
142
|
+
state[1][i] = _gf_mul(9, a[0]) ^ _gf_mul(14, a[1]) ^ _gf_mul(11, a[2]) ^ _gf_mul(13, a[3])
|
|
143
|
+
state[2][i] = _gf_mul(13, a[0]) ^ _gf_mul(9, a[1]) ^ _gf_mul(14, a[2]) ^ _gf_mul(11, a[3])
|
|
144
|
+
state[3][i] = _gf_mul(11, a[0]) ^ _gf_mul(13, a[1]) ^ _gf_mul(9, a[2]) ^ _gf_mul(14, a[3])
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _encrypt_block(block: bytes, w: list) -> bytes:
|
|
148
|
+
state = _bytes_to_state(block)
|
|
149
|
+
rk = w[:4]
|
|
150
|
+
_add_round_key(state, rk)
|
|
151
|
+
for rnd in range(1, 14):
|
|
152
|
+
_sub_bytes(state)
|
|
153
|
+
_shift_rows(state)
|
|
154
|
+
_mix_columns(state)
|
|
155
|
+
rk = w[4*rnd:4*(rnd+1)]
|
|
156
|
+
_add_round_key(state, rk)
|
|
157
|
+
_sub_bytes(state)
|
|
158
|
+
_shift_rows(state)
|
|
159
|
+
rk = w[56:60]
|
|
160
|
+
_add_round_key(state, rk)
|
|
161
|
+
return _state_to_bytes(state)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _decrypt_block(block: bytes, w: list) -> bytes:
|
|
165
|
+
state = _bytes_to_state(block)
|
|
166
|
+
rk = w[56:60]
|
|
167
|
+
_add_round_key(state, rk)
|
|
168
|
+
_inv_shift_rows(state)
|
|
169
|
+
_inv_sub_bytes(state)
|
|
170
|
+
for rnd in range(13, 0, -1):
|
|
171
|
+
rk = w[4*rnd:4*(rnd+1)]
|
|
172
|
+
_add_round_key(state, rk)
|
|
173
|
+
_inv_mix_columns(state)
|
|
174
|
+
_inv_shift_rows(state)
|
|
175
|
+
_inv_sub_bytes(state)
|
|
176
|
+
rk = w[:4]
|
|
177
|
+
_add_round_key(state, rk)
|
|
178
|
+
return _state_to_bytes(state)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _pkcs7_pad(data: bytes, block_size: int = 16) -> bytes:
|
|
182
|
+
pad_len = block_size - (len(data) % block_size)
|
|
183
|
+
return data + bytes([pad_len] * pad_len)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _pkcs7_unpad(data: bytes) -> bytes:
|
|
187
|
+
pad_len = data[-1]
|
|
188
|
+
if pad_len < 1 or pad_len > 16:
|
|
189
|
+
raise ValueError("Invalid padding")
|
|
190
|
+
for b in data[-pad_len:]:
|
|
191
|
+
if b != pad_len:
|
|
192
|
+
raise ValueError("Invalid padding")
|
|
193
|
+
return data[:-pad_len]
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def aes_cbc_encrypt(plaintext: bytes, key: bytes, iv: bytes) -> bytes:
|
|
197
|
+
w = _key_expansion(key)
|
|
198
|
+
ciphertext = bytearray()
|
|
199
|
+
prev = iv
|
|
200
|
+
for i in range(0, len(plaintext), 16):
|
|
201
|
+
block = plaintext[i:i+16]
|
|
202
|
+
xored = bytes(a ^ b for a, b in zip(block, prev))
|
|
203
|
+
enc = _encrypt_block(xored, w)
|
|
204
|
+
ciphertext.extend(enc)
|
|
205
|
+
prev = enc
|
|
206
|
+
return bytes(ciphertext)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def aes_cbc_decrypt(ciphertext: bytes, key: bytes, iv: bytes) -> bytes:
|
|
210
|
+
w = _key_expansion(key)
|
|
211
|
+
plaintext = bytearray()
|
|
212
|
+
prev = iv
|
|
213
|
+
for i in range(0, len(ciphertext), 16):
|
|
214
|
+
block = ciphertext[i:i+16]
|
|
215
|
+
dec = _decrypt_block(block, w)
|
|
216
|
+
xored = bytes(a ^ b for a, b in zip(dec, prev))
|
|
217
|
+
plaintext.extend(xored)
|
|
218
|
+
prev = block
|
|
219
|
+
return bytes(plaintext)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
PBKDF2_ITERATIONS = 600_000
|
|
223
|
+
SALT_LENGTH = 16
|
|
224
|
+
IV_LENGTH = 16
|
|
225
|
+
KEY_LENGTH = 32
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def derive_key(password: str, salt: bytes, dklen: int = KEY_LENGTH) -> bytes:
|
|
229
|
+
return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS, dklen=dklen)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def encrypt(data: dict, password: str) -> dict:
|
|
233
|
+
salt = os.urandom(SALT_LENGTH)
|
|
234
|
+
iv = os.urandom(IV_LENGTH)
|
|
235
|
+
derived = derive_key(password, salt, dklen=64)
|
|
236
|
+
enc_key = derived[:32]
|
|
237
|
+
mac_key = derived[32:]
|
|
238
|
+
plaintext = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
239
|
+
padded = _pkcs7_pad(plaintext)
|
|
240
|
+
ciphertext = aes_cbc_encrypt(padded, enc_key, iv)
|
|
241
|
+
mac = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
|
|
242
|
+
return {
|
|
243
|
+
"salt": base64.b64encode(salt).decode("ascii"),
|
|
244
|
+
"iv": base64.b64encode(iv).decode("ascii"),
|
|
245
|
+
"data": base64.b64encode(ciphertext).decode("ascii"),
|
|
246
|
+
"mac": base64.b64encode(mac).decode("ascii"),
|
|
247
|
+
"version": 3,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def decrypt(encrypted: dict, password: str) -> dict | None:
|
|
252
|
+
try:
|
|
253
|
+
salt = base64.b64decode(encrypted["salt"])
|
|
254
|
+
iv = base64.b64decode(encrypted["iv"])
|
|
255
|
+
ciphertext = base64.b64decode(encrypted["data"])
|
|
256
|
+
stored_mac = base64.b64decode(encrypted["mac"])
|
|
257
|
+
except Exception:
|
|
258
|
+
return None
|
|
259
|
+
|
|
260
|
+
version = encrypted.get("version", 1)
|
|
261
|
+
if version >= 3:
|
|
262
|
+
derived = derive_key(password, salt, dklen=64)
|
|
263
|
+
enc_key = derived[:32]
|
|
264
|
+
mac_key = derived[32:]
|
|
265
|
+
else:
|
|
266
|
+
enc_key = derive_key(password, salt)
|
|
267
|
+
mac_key = enc_key
|
|
268
|
+
|
|
269
|
+
expected_mac = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest()
|
|
270
|
+
if not hmac.compare_digest(stored_mac, expected_mac):
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
padded = aes_cbc_decrypt(ciphertext, enc_key, iv)
|
|
275
|
+
plaintext = _pkcs7_unpad(padded)
|
|
276
|
+
return json.loads(plaintext.decode("utf-8"))
|
|
277
|
+
except Exception as e:
|
|
278
|
+
print(f"Warning: decryption integrity check failed: {e}", file=sys.stderr)
|
|
279
|
+
return None
|
jkey/cli.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import importlib
|
|
3
|
+
import sys
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
|
|
6
|
+
from jkey.pm.core import (
|
|
7
|
+
add_password,
|
|
8
|
+
delete_password,
|
|
9
|
+
import_from_csv,
|
|
10
|
+
list_passwords,
|
|
11
|
+
show_password,
|
|
12
|
+
)
|
|
13
|
+
from jkey.pm.gen import generate_password
|
|
14
|
+
from jkey.pv.core import cmd_init, cmd_lock, cmd_set_pw, cmd_unlock, decrypt_file, encrypt_file
|
|
15
|
+
from jkey.pv.export import cmd_export
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="jkey",
|
|
21
|
+
description="Python library for password management and TOTP verification",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {version('jkey')}")
|
|
24
|
+
sub = parser.add_subparsers(dest="command")
|
|
25
|
+
|
|
26
|
+
p = sub.add_parser("2fa", help="Manage TOTP 2FA accounts")
|
|
27
|
+
p2 = p.add_subparsers(dest="action")
|
|
28
|
+
a = p2.add_parser("ls", help="List accounts and TOTP codes")
|
|
29
|
+
a.add_argument("keyword", nargs="?", default=None)
|
|
30
|
+
a = p2.add_parser("get", help="Show TOTP code")
|
|
31
|
+
a.add_argument("account")
|
|
32
|
+
a = p2.add_parser("add", help="Add account")
|
|
33
|
+
a.add_argument("name")
|
|
34
|
+
a.add_argument("secret")
|
|
35
|
+
a.add_argument("--recovery")
|
|
36
|
+
a = p2.add_parser("qr", help="Import from QR code")
|
|
37
|
+
a.add_argument("image_path")
|
|
38
|
+
a.add_argument("--recovery")
|
|
39
|
+
a = p2.add_parser("rm", help="Remove account")
|
|
40
|
+
a.add_argument("account")
|
|
41
|
+
|
|
42
|
+
p = sub.add_parser("pm", help="Manage passwords")
|
|
43
|
+
p2 = p.add_subparsers(dest="action")
|
|
44
|
+
g = p2.add_parser("gen", help="Generate random password")
|
|
45
|
+
g.add_argument("-L", "--length", type=int, default=16)
|
|
46
|
+
g.add_argument("--no-upper", action="store_true")
|
|
47
|
+
g.add_argument("--no-lower", action="store_true")
|
|
48
|
+
g.add_argument("--no-digits", action="store_true")
|
|
49
|
+
g.add_argument("--no-symbols", action="store_true")
|
|
50
|
+
a = p2.add_parser("ls", help="List stored passwords")
|
|
51
|
+
a.add_argument("keyword", nargs="?", default=None)
|
|
52
|
+
a = p2.add_parser("get", help="Show stored password")
|
|
53
|
+
a.add_argument("name")
|
|
54
|
+
a = p2.add_parser("add", help="Store password")
|
|
55
|
+
a.add_argument("name")
|
|
56
|
+
a = p2.add_parser("rm", help="Delete password")
|
|
57
|
+
a.add_argument("name")
|
|
58
|
+
a = p2.add_parser("import", help="Import from CSV")
|
|
59
|
+
a.add_argument("csv_path")
|
|
60
|
+
|
|
61
|
+
p = sub.add_parser("pv", help="Manage encrypted vault (passwd vault)")
|
|
62
|
+
p2 = p.add_subparsers(dest="action")
|
|
63
|
+
p2.add_parser("init", help="Initialize vault")
|
|
64
|
+
p2.add_parser("unlock", help="Unlock vault")
|
|
65
|
+
p2.add_parser("lock", help="Lock vault")
|
|
66
|
+
p2.add_parser("set-pw", help="Set master password")
|
|
67
|
+
e = p2.add_parser("encrypt", help="Encrypt a file")
|
|
68
|
+
e.add_argument("input")
|
|
69
|
+
e.add_argument("-o", "--output", help="Output .jkey path")
|
|
70
|
+
d = p2.add_parser("decrypt", help="Decrypt a .jkey file")
|
|
71
|
+
d.add_argument("input")
|
|
72
|
+
d.add_argument("-o", "--output", help="Output file path")
|
|
73
|
+
x = p2.add_parser("export", help="Export plaintext data (re-enters master password)")
|
|
74
|
+
x.add_argument("type", choices=["totp", "passwords", "recovery", "qr", "all"])
|
|
75
|
+
x.add_argument("-o", "--output", help="Output path (file or directory)")
|
|
76
|
+
|
|
77
|
+
args = parser.parse_args()
|
|
78
|
+
if args.command is None:
|
|
79
|
+
parser.print_help()
|
|
80
|
+
sys.exit(1)
|
|
81
|
+
|
|
82
|
+
if args.command == "2fa":
|
|
83
|
+
_2fa(args)
|
|
84
|
+
elif args.command == "pm":
|
|
85
|
+
_pm(args)
|
|
86
|
+
elif args.command == "pv":
|
|
87
|
+
_pv(args)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _2fa(args):
|
|
91
|
+
core = importlib.import_module("jkey.2fa.core")
|
|
92
|
+
a = args.action
|
|
93
|
+
if a == "ls":
|
|
94
|
+
core.list_accounts(args.keyword)
|
|
95
|
+
elif a == "get":
|
|
96
|
+
core.show_code(args.account)
|
|
97
|
+
elif a == "add":
|
|
98
|
+
core.add_account(args.name, args.secret, args.recovery)
|
|
99
|
+
elif a == "qr":
|
|
100
|
+
qr_mod = importlib.import_module("jkey.2fa.qr")
|
|
101
|
+
qr_mod.scan_and_add(args.image_path, args.recovery)
|
|
102
|
+
elif a == "rm":
|
|
103
|
+
core.remove_account(args.account)
|
|
104
|
+
else:
|
|
105
|
+
print("Usage: jkey 2fa ls|get|add|qr|rm")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _pm(args):
|
|
109
|
+
a = args.action
|
|
110
|
+
if a == "gen":
|
|
111
|
+
try:
|
|
112
|
+
pwd = generate_password(
|
|
113
|
+
length=args.length,
|
|
114
|
+
uppercase=not args.no_upper,
|
|
115
|
+
lowercase=not args.no_lower,
|
|
116
|
+
digits=not args.no_digits,
|
|
117
|
+
symbols=not args.no_symbols,
|
|
118
|
+
)
|
|
119
|
+
print(pwd)
|
|
120
|
+
except ValueError as e:
|
|
121
|
+
print(f"Error: {e}")
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
elif a == "ls":
|
|
124
|
+
list_passwords(args.keyword)
|
|
125
|
+
elif a == "get":
|
|
126
|
+
show_password(args.name)
|
|
127
|
+
elif a == "add":
|
|
128
|
+
add_password(args.name)
|
|
129
|
+
elif a == "rm":
|
|
130
|
+
delete_password(args.name)
|
|
131
|
+
elif a == "import":
|
|
132
|
+
import_from_csv(args.csv_path)
|
|
133
|
+
else:
|
|
134
|
+
print("Usage: jkey pm gen|ls|get|add|rm|import")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _pv(args):
|
|
138
|
+
a = args.action
|
|
139
|
+
if a == "init":
|
|
140
|
+
cmd_init()
|
|
141
|
+
elif a == "unlock":
|
|
142
|
+
cmd_unlock()
|
|
143
|
+
elif a == "lock":
|
|
144
|
+
cmd_lock()
|
|
145
|
+
elif a == "set-pw":
|
|
146
|
+
cmd_set_pw()
|
|
147
|
+
elif a == "encrypt":
|
|
148
|
+
encrypt_file(args.input, args.output)
|
|
149
|
+
elif a == "decrypt":
|
|
150
|
+
decrypt_file(args.input, args.output)
|
|
151
|
+
elif a == "export":
|
|
152
|
+
cmd_export(args)
|
|
153
|
+
else:
|
|
154
|
+
print("Usage: jkey pv init|unlock|lock|set-pw|encrypt|decrypt|export")
|
jkey/pm/__init__.py
ADDED
|
File without changes
|
jkey/pm/core.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import getpass
|
|
3
|
+
|
|
4
|
+
from jkey.pv.core import load_passwords, save_passwords
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def list_passwords(keyword: str | None = None):
|
|
8
|
+
data = load_passwords()
|
|
9
|
+
if data is None:
|
|
10
|
+
return
|
|
11
|
+
if not data:
|
|
12
|
+
print("No stored passwords found.")
|
|
13
|
+
return
|
|
14
|
+
keys = sorted(data.keys())
|
|
15
|
+
if keyword:
|
|
16
|
+
keys = [k for k in keys if keyword.lower() in k.lower()]
|
|
17
|
+
if not keys:
|
|
18
|
+
print(f"No passwords matching '{keyword}'.")
|
|
19
|
+
return
|
|
20
|
+
for name in keys:
|
|
21
|
+
print(f"{name}: {data[name]}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def show_password(name: str):
|
|
25
|
+
data = load_passwords()
|
|
26
|
+
if data is None:
|
|
27
|
+
return
|
|
28
|
+
if name not in data:
|
|
29
|
+
print(f"Error: Password '{name}' not found.")
|
|
30
|
+
return
|
|
31
|
+
print(f"{name}: {data[name]}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def add_password(name: str):
|
|
35
|
+
data = load_passwords()
|
|
36
|
+
if data is None:
|
|
37
|
+
return
|
|
38
|
+
pw = getpass.getpass(f"Password for '{name}': ")
|
|
39
|
+
if not pw:
|
|
40
|
+
print("Password cannot be empty.")
|
|
41
|
+
return
|
|
42
|
+
data[name] = pw
|
|
43
|
+
save_passwords(data)
|
|
44
|
+
print(f"Password stored: {name}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def delete_password(name: str):
|
|
48
|
+
data = load_passwords()
|
|
49
|
+
if data is None:
|
|
50
|
+
return
|
|
51
|
+
if name not in data:
|
|
52
|
+
print(f"Error: Password '{name}' not found.")
|
|
53
|
+
return
|
|
54
|
+
del data[name]
|
|
55
|
+
save_passwords(data)
|
|
56
|
+
print(f"Password deleted: {name}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def import_from_csv(csv_path: str):
|
|
60
|
+
data = load_passwords()
|
|
61
|
+
if data is None:
|
|
62
|
+
return
|
|
63
|
+
try:
|
|
64
|
+
with open(csv_path, "r", encoding="utf-8") as f:
|
|
65
|
+
reader = csv.reader(f)
|
|
66
|
+
rows = list(reader)
|
|
67
|
+
except OSError as e:
|
|
68
|
+
print(f"Error: Cannot read file '{csv_path}': {e}")
|
|
69
|
+
return
|
|
70
|
+
if not rows:
|
|
71
|
+
print("Error: Empty CSV file.")
|
|
72
|
+
return
|
|
73
|
+
first = rows[0]
|
|
74
|
+
if len(first) >= 2 and first[0].strip().lower() in ("name", "url", "account", "key"):
|
|
75
|
+
rows = rows[1:]
|
|
76
|
+
imported = 0
|
|
77
|
+
for row in rows:
|
|
78
|
+
if len(row) < 2:
|
|
79
|
+
continue
|
|
80
|
+
name = row[0].strip()
|
|
81
|
+
pw = row[1].strip()
|
|
82
|
+
if name and pw:
|
|
83
|
+
data[name] = pw
|
|
84
|
+
imported += 1
|
|
85
|
+
save_passwords(data)
|
|
86
|
+
print(f"Imported {imported} passwords from {csv_path}")
|
jkey/pm/gen.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
import string
|
|
3
|
+
|
|
4
|
+
_SYMBOLS = "!@#$%^&*()_+-=[]{}|;:,.<>?/"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def generate_password(
|
|
8
|
+
length: int = 16,
|
|
9
|
+
uppercase: bool = True,
|
|
10
|
+
lowercase: bool = True,
|
|
11
|
+
digits: bool = True,
|
|
12
|
+
symbols: bool = True,
|
|
13
|
+
) -> str:
|
|
14
|
+
char_sets = []
|
|
15
|
+
if lowercase:
|
|
16
|
+
char_sets.append(string.ascii_lowercase)
|
|
17
|
+
if uppercase:
|
|
18
|
+
char_sets.append(string.ascii_uppercase)
|
|
19
|
+
if digits:
|
|
20
|
+
char_sets.append(string.digits)
|
|
21
|
+
if symbols:
|
|
22
|
+
char_sets.append(_SYMBOLS)
|
|
23
|
+
if not char_sets:
|
|
24
|
+
raise ValueError("At least one character set must be selected.")
|
|
25
|
+
all_chars = "".join(char_sets)
|
|
26
|
+
mandatory = [secrets.choice(cs) for cs in char_sets]
|
|
27
|
+
if length < len(mandatory):
|
|
28
|
+
raise ValueError("Length too small to include all required character types.")
|
|
29
|
+
remaining = length - len(mandatory)
|
|
30
|
+
extra = [secrets.choice(all_chars) for _ in range(remaining)]
|
|
31
|
+
password_list = mandatory + extra
|
|
32
|
+
secrets.SystemRandom().shuffle(password_list)
|
|
33
|
+
return "".join(password_list)
|
jkey/pv/__init__.py
ADDED
|
File without changes
|
jkey/pv/core.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import getpass
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from jkey import aes
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".config", "jkey")
|
|
9
|
+
TOTP_FILE = os.path.join(CONFIG_DIR, "totp.jkey")
|
|
10
|
+
PASSWORDS_FILE = os.path.join(CONFIG_DIR, "passwords.jkey")
|
|
11
|
+
RECOVERY_FILE = os.path.join(CONFIG_DIR, "recovery.jkey")
|
|
12
|
+
QR_DIR = os.path.join(CONFIG_DIR, "qr")
|
|
13
|
+
|
|
14
|
+
_session_password: str | None = None
|
|
15
|
+
_totp_cache: dict | None = None
|
|
16
|
+
_passwords_cache: dict | None = None
|
|
17
|
+
_recovery_cache: dict | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _ensure_dir():
|
|
21
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
22
|
+
os.makedirs(QR_DIR, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _password_from_env() -> str | None:
|
|
26
|
+
return os.environ.get("JKEY_PASS")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _prompt_password(prompt: str = "Master password: ") -> str:
|
|
30
|
+
return getpass.getpass(prompt)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _read_jkey(path: str) -> dict | None:
|
|
34
|
+
if not os.path.exists(path):
|
|
35
|
+
return None
|
|
36
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
37
|
+
return json.load(f)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _write_jkey(path: str, encrypted: dict):
|
|
41
|
+
_ensure_dir()
|
|
42
|
+
tmp = path + ".tmp"
|
|
43
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
44
|
+
json.dump(encrypted, f, indent=4, ensure_ascii=False)
|
|
45
|
+
os.replace(tmp, path)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _decrypt_file(path: str, password: str) -> dict | None:
|
|
49
|
+
encrypted = _read_jkey(path)
|
|
50
|
+
if encrypted is None:
|
|
51
|
+
return None
|
|
52
|
+
return aes.decrypt(encrypted, password)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _encrypt_file(path: str, data: dict, password: str):
|
|
56
|
+
encrypted = aes.encrypt(data, password)
|
|
57
|
+
_write_jkey(path, encrypted)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _unlock_all(password: str) -> bool:
|
|
61
|
+
global _session_password, _totp_cache, _passwords_cache, _recovery_cache
|
|
62
|
+
data = _decrypt_file(TOTP_FILE, password)
|
|
63
|
+
if data is None:
|
|
64
|
+
return False
|
|
65
|
+
_totp_cache = data
|
|
66
|
+
_passwords_cache = _decrypt_file(PASSWORDS_FILE, password) or {}
|
|
67
|
+
_recovery_cache = _decrypt_file(RECOVERY_FILE, password) or {}
|
|
68
|
+
_session_password = password
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def verify_password(password: str) -> bool:
|
|
73
|
+
encrypted = _read_jkey(TOTP_FILE)
|
|
74
|
+
if encrypted is None:
|
|
75
|
+
return False
|
|
76
|
+
return aes.decrypt(encrypted, password) is not None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _ensure_unlocked():
|
|
80
|
+
if is_unlocked():
|
|
81
|
+
return True
|
|
82
|
+
if not os.path.exists(TOTP_FILE):
|
|
83
|
+
print("Error: Vault not initialized. Run 'jkey pv init' first.")
|
|
84
|
+
return False
|
|
85
|
+
pw = _password_from_env()
|
|
86
|
+
if pw and _unlock_all(pw):
|
|
87
|
+
return True
|
|
88
|
+
for _ in range(3):
|
|
89
|
+
pw = _prompt_password()
|
|
90
|
+
if _unlock_all(pw):
|
|
91
|
+
return True
|
|
92
|
+
print("Incorrect password. Try again.")
|
|
93
|
+
print("Failed to unlock vault.")
|
|
94
|
+
return False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_unlocked() -> bool:
|
|
98
|
+
return _session_password is not None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def lock():
|
|
102
|
+
global _session_password, _totp_cache, _passwords_cache, _recovery_cache
|
|
103
|
+
_session_password = None
|
|
104
|
+
_totp_cache = None
|
|
105
|
+
_passwords_cache = None
|
|
106
|
+
_recovery_cache = None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def load_totp() -> dict | None:
|
|
110
|
+
if not _ensure_unlocked():
|
|
111
|
+
return None
|
|
112
|
+
return _totp_cache
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def save_totp(data: dict):
|
|
116
|
+
global _totp_cache
|
|
117
|
+
if _session_password is None:
|
|
118
|
+
return
|
|
119
|
+
_encrypt_file(TOTP_FILE, data, _session_password)
|
|
120
|
+
_totp_cache = data
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load_passwords() -> dict | None:
|
|
124
|
+
if not _ensure_unlocked():
|
|
125
|
+
return None
|
|
126
|
+
return _passwords_cache
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def save_passwords(data: dict):
|
|
130
|
+
global _passwords_cache
|
|
131
|
+
if _session_password is None:
|
|
132
|
+
return
|
|
133
|
+
_encrypt_file(PASSWORDS_FILE, data, _session_password)
|
|
134
|
+
_passwords_cache = data
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def load_recovery() -> dict | None:
|
|
138
|
+
if not _ensure_unlocked():
|
|
139
|
+
return None
|
|
140
|
+
return _recovery_cache
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def save_recovery(data: dict):
|
|
144
|
+
global _recovery_cache
|
|
145
|
+
if _session_password is None:
|
|
146
|
+
return
|
|
147
|
+
_encrypt_file(RECOVERY_FILE, data, _session_password)
|
|
148
|
+
_recovery_cache = data
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def save_qr_image(name: str, image_data: bytes):
|
|
152
|
+
if _session_password is None:
|
|
153
|
+
return
|
|
154
|
+
_ensure_dir()
|
|
155
|
+
encoded = base64.b64encode(image_data).decode("ascii")
|
|
156
|
+
encrypted = aes.encrypt({"raw": encoded}, _session_password)
|
|
157
|
+
path = os.path.join(QR_DIR, f"{name}.jkey")
|
|
158
|
+
_write_jkey(path, encrypted)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load_qr_image(name: str) -> bytes | None:
|
|
162
|
+
if not _ensure_unlocked():
|
|
163
|
+
return None
|
|
164
|
+
path = os.path.join(QR_DIR, f"{name}.jkey")
|
|
165
|
+
if not os.path.exists(path):
|
|
166
|
+
return None
|
|
167
|
+
encrypted = _read_jkey(path)
|
|
168
|
+
if encrypted is None:
|
|
169
|
+
return None
|
|
170
|
+
data = aes.decrypt(encrypted, _session_password)
|
|
171
|
+
if data is None or "raw" not in data:
|
|
172
|
+
return None
|
|
173
|
+
return base64.b64decode(data["raw"])
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def list_qr_images() -> list[str]:
|
|
177
|
+
if not os.path.exists(QR_DIR):
|
|
178
|
+
return []
|
|
179
|
+
names = []
|
|
180
|
+
for f in os.listdir(QR_DIR):
|
|
181
|
+
if f.endswith(".jkey"):
|
|
182
|
+
names.append(f[:-5])
|
|
183
|
+
return sorted(names)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def encrypt_file(input_path: str, output_path: str | None = None):
|
|
187
|
+
if not os.path.exists(input_path):
|
|
188
|
+
print(f"Error: File not found: {input_path}")
|
|
189
|
+
return
|
|
190
|
+
if not _ensure_unlocked():
|
|
191
|
+
return
|
|
192
|
+
with open(input_path, "rb") as f:
|
|
193
|
+
raw = f.read()
|
|
194
|
+
encoded = base64.b64encode(raw).decode("ascii")
|
|
195
|
+
encrypted = aes.encrypt({"raw": encoded}, _session_password)
|
|
196
|
+
if output_path is None:
|
|
197
|
+
output_path = input_path + ".jkey"
|
|
198
|
+
_write_jkey(output_path, encrypted)
|
|
199
|
+
print(f"Encrypted: {output_path}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def decrypt_file(path: str, output_path: str | None = None):
|
|
203
|
+
if not os.path.exists(path):
|
|
204
|
+
print(f"Error: File not found: {path}")
|
|
205
|
+
return
|
|
206
|
+
if not _ensure_unlocked():
|
|
207
|
+
return
|
|
208
|
+
encrypted = _read_jkey(path)
|
|
209
|
+
if encrypted is None:
|
|
210
|
+
return
|
|
211
|
+
data = aes.decrypt(encrypted, _session_password)
|
|
212
|
+
if data is None:
|
|
213
|
+
print("Error: Decryption failed.")
|
|
214
|
+
return
|
|
215
|
+
if "raw" in data:
|
|
216
|
+
raw = base64.b64decode(data["raw"])
|
|
217
|
+
else:
|
|
218
|
+
raw = json.dumps(data, indent=4, ensure_ascii=False).encode("utf-8")
|
|
219
|
+
if output_path:
|
|
220
|
+
with open(output_path, "wb") as f:
|
|
221
|
+
f.write(raw)
|
|
222
|
+
print(f"Decrypted: {output_path}")
|
|
223
|
+
else:
|
|
224
|
+
if "raw" in data:
|
|
225
|
+
print("(binary data, use -o <file> to save)")
|
|
226
|
+
else:
|
|
227
|
+
print(raw.decode("utf-8"))
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def cmd_init():
|
|
231
|
+
if os.path.exists(TOTP_FILE):
|
|
232
|
+
print("Vault already exists. Use 'jkey pv set-pw' to change password.")
|
|
233
|
+
return
|
|
234
|
+
pw1 = _password_from_env()
|
|
235
|
+
if pw1 is None:
|
|
236
|
+
pw1 = _prompt_password("Set master password: ")
|
|
237
|
+
if not pw1:
|
|
238
|
+
print("Password cannot be empty.")
|
|
239
|
+
return
|
|
240
|
+
pw2 = _prompt_password("Confirm master password: ")
|
|
241
|
+
if pw1 != pw2:
|
|
242
|
+
print("Passwords do not match.")
|
|
243
|
+
return
|
|
244
|
+
_ensure_dir()
|
|
245
|
+
_encrypt_file(TOTP_FILE, {}, pw1)
|
|
246
|
+
_encrypt_file(PASSWORDS_FILE, {}, pw1)
|
|
247
|
+
_encrypt_file(RECOVERY_FILE, {}, pw1)
|
|
248
|
+
_unlock_all(pw1)
|
|
249
|
+
print(f"Vault initialized at {CONFIG_DIR}")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmd_unlock():
|
|
253
|
+
if is_unlocked():
|
|
254
|
+
print("Vault is already unlocked.")
|
|
255
|
+
return
|
|
256
|
+
if not os.path.exists(TOTP_FILE):
|
|
257
|
+
print("Error: Vault not initialized. Run 'jkey pv init' first.")
|
|
258
|
+
return
|
|
259
|
+
if _ensure_unlocked():
|
|
260
|
+
print("Vault unlocked.")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def cmd_lock():
|
|
264
|
+
if not is_unlocked():
|
|
265
|
+
print("Vault is already locked.")
|
|
266
|
+
return
|
|
267
|
+
lock()
|
|
268
|
+
print("Vault locked.")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def cmd_set_pw():
|
|
272
|
+
if not os.path.exists(TOTP_FILE):
|
|
273
|
+
print("Error: Vault not initialized. Run 'jkey pv init' first.")
|
|
274
|
+
return
|
|
275
|
+
if not _ensure_unlocked():
|
|
276
|
+
return
|
|
277
|
+
pw1 = _prompt_password("New master password: ")
|
|
278
|
+
if not pw1:
|
|
279
|
+
print("Password cannot be empty.")
|
|
280
|
+
return
|
|
281
|
+
pw2 = _prompt_password("Confirm new master password: ")
|
|
282
|
+
if pw1 != pw2:
|
|
283
|
+
print("Passwords do not match.")
|
|
284
|
+
return
|
|
285
|
+
global _session_password
|
|
286
|
+
_encrypt_file(TOTP_FILE, _totp_cache, pw1)
|
|
287
|
+
_encrypt_file(PASSWORDS_FILE, _passwords_cache, pw1)
|
|
288
|
+
_encrypt_file(RECOVERY_FILE, _recovery_cache, pw1)
|
|
289
|
+
_session_password = pw1
|
|
290
|
+
print("Master password changed.")
|
jkey/pv/export.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import getpass
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
from jkey.pv.core import (
|
|
8
|
+
_ensure_unlocked,
|
|
9
|
+
_password_from_env,
|
|
10
|
+
list_qr_images,
|
|
11
|
+
load_passwords,
|
|
12
|
+
load_qr_image,
|
|
13
|
+
load_recovery,
|
|
14
|
+
load_totp,
|
|
15
|
+
verify_password,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def cmd_export(args):
|
|
20
|
+
if not _ensure_unlocked():
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
env_pw = _password_from_env()
|
|
24
|
+
if env_pw and verify_password(env_pw):
|
|
25
|
+
pass
|
|
26
|
+
else:
|
|
27
|
+
pw = getpass.getpass("Confirm master password to export: ")
|
|
28
|
+
if not verify_password(pw):
|
|
29
|
+
print("Incorrect password. Export cancelled.")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
t = args.type
|
|
33
|
+
output = args.output
|
|
34
|
+
|
|
35
|
+
if t == "totp":
|
|
36
|
+
data = load_totp()
|
|
37
|
+
if data is None:
|
|
38
|
+
return
|
|
39
|
+
out = json.dumps(data, indent=4, ensure_ascii=False) + "\n"
|
|
40
|
+
if output:
|
|
41
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
42
|
+
f.write(out)
|
|
43
|
+
print(f"Exported TOTP secrets to {output}")
|
|
44
|
+
else:
|
|
45
|
+
print(out, end="")
|
|
46
|
+
|
|
47
|
+
elif t == "passwords":
|
|
48
|
+
data = load_passwords()
|
|
49
|
+
if data is None:
|
|
50
|
+
return
|
|
51
|
+
buf = io.StringIO()
|
|
52
|
+
w = csv.writer(buf)
|
|
53
|
+
w.writerow(["name", "password"])
|
|
54
|
+
for name, pw_val in sorted(data.items()):
|
|
55
|
+
w.writerow([name, pw_val])
|
|
56
|
+
out = buf.getvalue()
|
|
57
|
+
if output:
|
|
58
|
+
with open(output, "w", encoding="utf-8", newline="") as f:
|
|
59
|
+
f.write(out)
|
|
60
|
+
print(f"Exported passwords to {output}")
|
|
61
|
+
else:
|
|
62
|
+
print(out, end="")
|
|
63
|
+
|
|
64
|
+
elif t == "recovery":
|
|
65
|
+
data = load_recovery()
|
|
66
|
+
if data is None:
|
|
67
|
+
return
|
|
68
|
+
lines = []
|
|
69
|
+
for account in sorted(data.keys()):
|
|
70
|
+
lines.append(f"Account: {account}")
|
|
71
|
+
for code in data[account]:
|
|
72
|
+
lines.append(f" {code}")
|
|
73
|
+
lines.append("")
|
|
74
|
+
out = "\n".join(lines)
|
|
75
|
+
if output:
|
|
76
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
77
|
+
f.write(out)
|
|
78
|
+
f.write("\n")
|
|
79
|
+
print(f"Exported recovery codes to {output}")
|
|
80
|
+
else:
|
|
81
|
+
print(out)
|
|
82
|
+
|
|
83
|
+
elif t == "qr":
|
|
84
|
+
if not output:
|
|
85
|
+
print("Error: -o <directory> required for QR export.")
|
|
86
|
+
return
|
|
87
|
+
os.makedirs(output, exist_ok=True)
|
|
88
|
+
names = list_qr_images()
|
|
89
|
+
if not names:
|
|
90
|
+
print("No QR images found.")
|
|
91
|
+
return
|
|
92
|
+
for name in names:
|
|
93
|
+
img = load_qr_image(name)
|
|
94
|
+
if img:
|
|
95
|
+
path = os.path.join(output, f"{name}.jpg")
|
|
96
|
+
with open(path, "wb") as f:
|
|
97
|
+
f.write(img)
|
|
98
|
+
print(f"Exported {len(names)} QR images to {output}")
|
|
99
|
+
|
|
100
|
+
elif t == "all":
|
|
101
|
+
if not output:
|
|
102
|
+
print("Error: -o <directory> required for full export.")
|
|
103
|
+
return
|
|
104
|
+
os.makedirs(output, exist_ok=True)
|
|
105
|
+
|
|
106
|
+
totp_data = load_totp()
|
|
107
|
+
if totp_data:
|
|
108
|
+
p = os.path.join(output, "totp.json")
|
|
109
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
110
|
+
json.dump(totp_data, f, indent=4, ensure_ascii=False)
|
|
111
|
+
f.write("\n")
|
|
112
|
+
print(f" {p}")
|
|
113
|
+
|
|
114
|
+
pw_data = load_passwords()
|
|
115
|
+
if pw_data:
|
|
116
|
+
p = os.path.join(output, "passwords.csv")
|
|
117
|
+
with open(p, "w", encoding="utf-8", newline="") as f:
|
|
118
|
+
w = csv.writer(f)
|
|
119
|
+
w.writerow(["name", "password"])
|
|
120
|
+
for name, pw_val in sorted(pw_data.items()):
|
|
121
|
+
w.writerow([name, pw_val])
|
|
122
|
+
print(f" {p}")
|
|
123
|
+
|
|
124
|
+
rc_data = load_recovery()
|
|
125
|
+
if rc_data:
|
|
126
|
+
p = os.path.join(output, "recovery.txt")
|
|
127
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
128
|
+
for account in sorted(rc_data.keys()):
|
|
129
|
+
f.write(f"Account: {account}\n")
|
|
130
|
+
for code in rc_data[account]:
|
|
131
|
+
f.write(f" {code}\n")
|
|
132
|
+
f.write("\n")
|
|
133
|
+
print(f" {p}")
|
|
134
|
+
|
|
135
|
+
qr_dir = os.path.join(output, "qr")
|
|
136
|
+
names = list_qr_images()
|
|
137
|
+
if names:
|
|
138
|
+
os.makedirs(qr_dir, exist_ok=True)
|
|
139
|
+
for name in names:
|
|
140
|
+
img = load_qr_image(name)
|
|
141
|
+
if img:
|
|
142
|
+
p = os.path.join(qr_dir, f"{name}.jpg")
|
|
143
|
+
with open(p, "wb") as f:
|
|
144
|
+
f.write(img)
|
|
145
|
+
print(f" {qr_dir}/ ({len(names)} images)")
|
|
146
|
+
|
|
147
|
+
print(f"Exported to {output}")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jkey
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Python library for password management and TOTP verification.
|
|
5
|
+
Author-email: jiaoyuan <imjiaoyuan@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/imjiaoyuan/jkey
|
|
8
|
+
Project-URL: Repository, https://github.com/imjiaoyuan/jkey
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
Requires-Dist: opencv-python-headless>=4.9.0
|
|
12
|
+
|
|
13
|
+
# jkey
|
|
14
|
+
|
|
15
|
+
Python library for password management and TOTP verification.
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv tool install jkey
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or run without installing:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
uv run jkey --help
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Initialize vault (set master password)
|
|
33
|
+
jkey pv init
|
|
34
|
+
|
|
35
|
+
# Add a 2FA account
|
|
36
|
+
jkey 2fa add github JBSWY3DPEHPK3PXP
|
|
37
|
+
|
|
38
|
+
# Get current TOTP code
|
|
39
|
+
jkey 2fa get github
|
|
40
|
+
|
|
41
|
+
# Import from QR code image
|
|
42
|
+
jkey 2fa qr ./github.jpg
|
|
43
|
+
|
|
44
|
+
# Generate a random password
|
|
45
|
+
jkey pm gen -L 24
|
|
46
|
+
|
|
47
|
+
# Store a password
|
|
48
|
+
jkey pm add my-site
|
|
49
|
+
|
|
50
|
+
# List all stored passwords
|
|
51
|
+
jkey pm ls
|
|
52
|
+
|
|
53
|
+
# Encrypt/decrypt any file
|
|
54
|
+
jkey pv encrypt secret.pdf
|
|
55
|
+
jkey pv decrypt secret.pdf.jkey -o secret.pdf
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Commands
|
|
59
|
+
|
|
60
|
+
| Command | Description |
|
|
61
|
+
|---------|-------------|
|
|
62
|
+
| `jkey 2fa ls [keyword]` | List TOTP accounts and codes |
|
|
63
|
+
| `jkey 2fa get <account>` | Show TOTP code for an account |
|
|
64
|
+
| `jkey 2fa add <name> <secret>` | Add a TOTP account |
|
|
65
|
+
| `jkey 2fa qr <image>` | Import from QR code image |
|
|
66
|
+
| `jkey 2fa rm <account>` | Remove a TOTP account |
|
|
67
|
+
| `jkey pm gen [-L N]` | Generate a random password |
|
|
68
|
+
| `jkey pm ls [keyword]` | List stored passwords |
|
|
69
|
+
| `jkey pm get <name>` | Show a stored password |
|
|
70
|
+
| `jkey pm add <name>` | Store a password (prompts for input) |
|
|
71
|
+
| `jkey pm rm <name>` | Delete a stored password |
|
|
72
|
+
| `jkey pm import <csv>` | Import passwords from CSV (name,password) |
|
|
73
|
+
| `jkey pv init` | Initialize the encrypted vault |
|
|
74
|
+
| `jkey pv unlock` | Unlock the vault |
|
|
75
|
+
| `jkey pv lock` | Lock the vault |
|
|
76
|
+
| `jkey pv set-pw` | Change master password |
|
|
77
|
+
| `jkey pv encrypt <file>` | Encrypt a file |
|
|
78
|
+
| `jkey pv decrypt <file>` | Decrypt a `.jkey` file |
|
|
79
|
+
| `jkey pv export totp` | Export TOTP secrets (re-enters master password) |
|
|
80
|
+
| `jkey pv export passwords` | Export passwords as CSV |
|
|
81
|
+
| `jkey pv export recovery` | Export recovery codes |
|
|
82
|
+
| `jkey pv export qr -o <dir>` | Export QR code images |
|
|
83
|
+
| `jkey pv export all -o <dir>` | Export everything |
|
|
84
|
+
|
|
85
|
+
Set `JKEY_PASS` environment variable to skip the password prompt.
|
|
86
|
+
|
|
87
|
+
## How It Works
|
|
88
|
+
|
|
89
|
+
Data is encrypted with AES-256-CBC + HMAC-SHA256 and stored in `~/.config/jkey/`:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
~/.config/jkey/
|
|
93
|
+
├── totp.jkey # Encrypted TOTP secrets
|
|
94
|
+
├── passwords.jkey # Encrypted passwords
|
|
95
|
+
├── recovery.jkey # Encrypted recovery codes
|
|
96
|
+
└── qr/ # Encrypted QR images
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Back up `~/.config/jkey/` to migrate to another machine.
|
|
100
|
+
|
|
101
|
+
## Dependencies
|
|
102
|
+
|
|
103
|
+
- `opencv-python-headless` — QR code scanning
|
|
104
|
+
|
|
105
|
+
Pure Python, no OpenSSL or libsodium required.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
jkey/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
jkey/__main__.py,sha256=PrHKvvvZNLoxd6bdOJGCmQUZvrZsemvtf9o7Lhlg0PI,65
|
|
3
|
+
jkey/aes.py,sha256=UH2foWA2_FgfeWpVYOP9HVDVIPpnAdUHgH0tidxlvOY,9274
|
|
4
|
+
jkey/cli.py,sha256=ChJe6ctZk0BGICHi5Do357cykxBF6-8l07X1YqfNDGo,5176
|
|
5
|
+
jkey/2fa/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
jkey/2fa/core.py,sha256=iJrNW3glXrbQele2HCdB5t5E4h0w7xNNoFcw2Hctvos,3389
|
|
7
|
+
jkey/2fa/qr.py,sha256=WVs_rT04xJoo5voPpzBSvZ2-XVACUNvFL8y_b4THQ2Q,1661
|
|
8
|
+
jkey/pm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
jkey/pm/core.py,sha256=tX6G04lHmmx0RsRLJzCjMTNgHdSACEel4HpzxojphTA,2193
|
|
10
|
+
jkey/pm/gen.py,sha256=PP7k7ldpOm--L95bSq1Vo6rWF_-L49KJKzIsgJ8_AhQ,1027
|
|
11
|
+
jkey/pv/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
jkey/pv/core.py,sha256=UxdiTXEu_e5440N9h13LB7ZU3q-ZQwKCTy2187MtFD0,7981
|
|
13
|
+
jkey/pv/export.py,sha256=-ztD7y9zYv-D8EcsoGLFEtneJUJ2IcxhKMiB5voJTek,4471
|
|
14
|
+
jkey-0.1.2.dist-info/METADATA,sha256=jfmu-JlCjfXmccn2TgGsOg4kbQ4RqUr2tCf8A5_6SEA,2880
|
|
15
|
+
jkey-0.1.2.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
16
|
+
jkey-0.1.2.dist-info/entry_points.txt,sha256=Fe2Oba3cO6DG5h0Ybx9_H4vuqhaujIfnfJXQAERye1M,39
|
|
17
|
+
jkey-0.1.2.dist-info/top_level.txt,sha256=B668dz5cpxfnglJdSEtVBrOK5tHbsFwh0J7I1NB0Lp0,5
|
|
18
|
+
jkey-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jkey
|