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 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
@@ -0,0 +1,4 @@
1
+ from jkey.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jkey = jkey.cli:main
@@ -0,0 +1 @@
1
+ jkey