passlocker 1.0.1__tar.gz

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.
@@ -0,0 +1,19 @@
1
+ Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License
2
+
3
+ By exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Public License ("Public License").
4
+
5
+ You may not use the material for commercial purposes.
6
+
7
+ You are free to:
8
+ - Share — copy and redistribute the material in any medium or format
9
+ - Adapt — remix, transform, and build upon the material
10
+
11
+ Under the following terms:
12
+ - Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made.
13
+ - NonCommercial — You may not use the material for commercial purposes.
14
+ - ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
15
+ - No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
16
+
17
+ For the full license text, see: https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode
18
+
19
+ SPDX-License-Identifier: CC-BY-NC-SA-4.0
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: passlocker
3
+ Version: 1.0.1
4
+ Summary: Password Encryption
5
+ Author-email: Chris Lee <github@chrislee.dhs.org>
6
+ License: CC-BY-NC-SA-4.0
7
+ Project-URL: Homepage, https://github.com/chrislee35/passlocker
8
+ Keywords: encryption,passwords,security
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Topic :: Utilities
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Requires-Python: >=3.12
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: pycryptodome>=3.23.0
16
+ Requires-Dist: pyperclip>=1.11.0
17
+ Requires-Dist: pwinput>=1.0.3
18
+ Requires-Dist: argon2-cffi>=25.1.0
19
+ Requires-Dist: requests>=2.32.5
20
+ Requires-Dist: qrcode>=8.2
21
+ Requires-Dist: colorama>=0.4.6
22
+ Provides-Extra: dev
23
+ Requires-Dist: build; extra == "dev"
24
+ Requires-Dist: twine; extra == "dev"
25
+ Requires-Dist: black; extra == "dev"
26
+ Requires-Dist: ruff; extra == "dev"
27
+ Dynamic: license-file
28
+
29
+ # Overview
30
+
31
+ PassLocker creates a password repository.
32
+
33
+ # View help
34
+
35
+ passlocker help
36
+
37
+ # Listing Accounts
38
+
39
+ passlocker list
40
+
41
+ # Get information on a named account
42
+
43
+ passlocker info "test account"
44
+ passlocker info hex 74657374206163636f756e74
45
+ passlocker info b64 dGVzdCBhY2NvdW50Cg==
46
+
47
+ # Get the active password on a named account
48
+
49
+ passlocker get "test account"
50
+
51
+ # Add a simple password-based account
52
+
53
+ passlocker add "test account 2" password
54
+
55
+ # Add a One-Time-Password account
56
+
57
+ passlocker add "otp account" otp
58
+
59
+ # Add a Time-based One-Time-Password account
60
+
61
+ passlocker add "totp account" totp [start_time] [interval] [num_digits] [hash_algo]
62
+
63
+ # Set the username for an account
64
+
65
+ passlocker user "test account 2" "<username>"
66
+
67
+ # Add a note to an account
68
+
69
+ passlocker note "test account 2" "<note>"
70
+
71
+ # Add a password to a password account
72
+
73
+ passlocker pw "test account 2" [raw|hex|b64] [file <filename>]
74
+
75
+ # Add OTP passwords
76
+
77
+ passlocker pw "otp account" [raw|hex|b64] [file <filename>]
78
+
79
+ # Add a TOTP secret
80
+
81
+ passlocker pw "totp account" [raw|hex|b64] [file <filename>]
82
+
83
+
@@ -0,0 +1,55 @@
1
+ # Overview
2
+
3
+ PassLocker creates a password repository.
4
+
5
+ # View help
6
+
7
+ passlocker help
8
+
9
+ # Listing Accounts
10
+
11
+ passlocker list
12
+
13
+ # Get information on a named account
14
+
15
+ passlocker info "test account"
16
+ passlocker info hex 74657374206163636f756e74
17
+ passlocker info b64 dGVzdCBhY2NvdW50Cg==
18
+
19
+ # Get the active password on a named account
20
+
21
+ passlocker get "test account"
22
+
23
+ # Add a simple password-based account
24
+
25
+ passlocker add "test account 2" password
26
+
27
+ # Add a One-Time-Password account
28
+
29
+ passlocker add "otp account" otp
30
+
31
+ # Add a Time-based One-Time-Password account
32
+
33
+ passlocker add "totp account" totp [start_time] [interval] [num_digits] [hash_algo]
34
+
35
+ # Set the username for an account
36
+
37
+ passlocker user "test account 2" "<username>"
38
+
39
+ # Add a note to an account
40
+
41
+ passlocker note "test account 2" "<note>"
42
+
43
+ # Add a password to a password account
44
+
45
+ passlocker pw "test account 2" [raw|hex|b64] [file <filename>]
46
+
47
+ # Add OTP passwords
48
+
49
+ passlocker pw "otp account" [raw|hex|b64] [file <filename>]
50
+
51
+ # Add a TOTP secret
52
+
53
+ passlocker pw "totp account" [raw|hex|b64] [file <filename>]
54
+
55
+
@@ -0,0 +1 @@
1
+ from .passlocker import PassLocker
@@ -0,0 +1,4 @@
1
+ from .cui import main
2
+
3
+ if __name__ == '__main__':
4
+ main()
@@ -0,0 +1,430 @@
1
+ from _io import StringIO
2
+ from qrcode import QRCode
3
+ from typing import Callable, Literal, LiteralString, cast
4
+ from passlocker.models.account import Account
5
+ from .passlocker import PassLocker
6
+ import getpass
7
+ import io
8
+ import os
9
+ import base64
10
+ import pyperclip
11
+ from binascii import unhexlify
12
+ from pprint import pprint
13
+ import secrets
14
+ from colorama import Fore, Style
15
+
16
+ def dec(item: str, encoding: str='raw') -> bytes | str:
17
+ if encoding == 'hex':
18
+ return unhexlify(item)
19
+ elif encoding == 'b64':
20
+ return base64.b64decode(item)
21
+ elif encoding == 'raw':
22
+ return item.encode('UTF-8')
23
+ elif encoding == 'bytes':
24
+ return item
25
+ return item
26
+
27
+ def pl_prompt(message: str, default: str = "", options: list[str] | None = None) -> str:
28
+ while True:
29
+ if options:
30
+ print("Options: [%s]" % ", ".join(options))
31
+
32
+ a: str = input(f'{message}: [{default}] ')
33
+ if a == "":
34
+ a = default
35
+
36
+ if options:
37
+ if a in options:
38
+ return a
39
+ if a in [o[0] for o in options]:
40
+ return a
41
+ else:
42
+ return a
43
+
44
+ def pl_prompt_int(message: str, default: int = 0) -> int:
45
+ while True:
46
+ a: str = input(f'{message}: [{default}] ')
47
+ if a == "":
48
+ return default
49
+ try:
50
+ return int(a)
51
+ except ValueError:
52
+ return default
53
+
54
+ class CUI:
55
+ def __init__(self,
56
+ password_callback: Callable[[str], bytes] | None = None,
57
+ dbdir: str | None = None
58
+ ) -> None:
59
+ self.dbdir: str = '%s/.passlocker' % os.environ['HOME']
60
+ if dbdir:
61
+ self.dbdir = dbdir
62
+ self.words: list[str] = list[str]()
63
+ if password_callback is None:
64
+ password_callback = self.get_master_password
65
+ self.pl = PassLocker(password_callback, dbdir=self.dbdir)
66
+
67
+ def next_word(self) -> str | None:
68
+ if len(self.words) == 0:
69
+ return None
70
+ word: str = self.words.pop(0)
71
+ return word
72
+
73
+ def help_main(self):
74
+ print("""help prints out this help message
75
+ list list password entries
76
+ add creates a new password entry
77
+ genpass randomly generates passwords
78
+ chpass change the master password
79
+ pwned check all passwords (via hash) against haveibeenpwned.com
80
+ exit exits the tool""")
81
+
82
+ def help_list(self):
83
+ print("""help prints out this help message
84
+ info provides information about a password entry
85
+ get retrieves password for a password entry
86
+ addpw adds or updates the password for a password entry
87
+ del deletes a password entry
88
+ note add a note to the password entry
89
+ rename rename this account
90
+ pwned check current password (via hash) against haveibeenpwned.com
91
+ exit returns to the top menu""")
92
+
93
+ def menu(self):
94
+ cmd = pl_prompt("Main menu", "exit", ["list", "add", "genpass", "chpass", "clear", "help", "pwned", "version", "exit"])
95
+ if cmd in ["help", 'h']:
96
+ self.help_main()
97
+ elif cmd in ["list", "l"]:
98
+ self.list_accounts()
99
+ elif cmd in ["add", 'a']:
100
+ self.add_account()
101
+ elif cmd in ["genpass", 'g']:
102
+ _ = self.generate_password_menu()
103
+ elif cmd in ["chpass", "c"]:
104
+ self.pl.change_master_password()
105
+ elif cmd in ['exit', 'e']:
106
+ return False
107
+ elif cmd in ["clear"]:
108
+ _ = os.system("clear")
109
+ elif cmd in ["pwned", "p"]:
110
+ self.check_all_passwords()
111
+ elif cmd in ["version", "v"]:
112
+ print(f"Passlocker version {self.pl.PASSLOCKER_VERSION}")
113
+ return True
114
+
115
+ def list_accounts(self):
116
+ filt: str = input("search filter: ")
117
+ item: int = 0
118
+ accounts: list[Account] = [acc for acc in self.pl.accounts() if filt in acc.account or filt in acc.username]
119
+ item = 0
120
+ if len(accounts) == 0:
121
+ print("No accounts found")
122
+ return
123
+
124
+ for acc in accounts:
125
+ print(f"{item}\t{acc.account}\t{acc.username}")
126
+ item += 1
127
+
128
+ idx: int = pl_prompt_int("entry #", 0)
129
+ if idx >= len(accounts):
130
+ return
131
+ acc: Account = accounts[idx]
132
+ self.edit_account(acc)
133
+
134
+ def edit_account(self, account: Account):
135
+ accname = account.account
136
+ username = account.username
137
+
138
+ while True:
139
+ cmd = pl_prompt(f"{accname} {username}",
140
+ default="exit",
141
+ options=["help","info","get","copy","qrcode","addpw","genpass","test","del","exit","note","rename","clear","pwned"]
142
+ )
143
+ if cmd in ["help", 'h']:
144
+ self.help_list()
145
+ elif cmd in ["info", 'i']:
146
+ pprint(account)
147
+ elif cmd in ["get", 'g']:
148
+ try:
149
+ pw: bytes = account.get_active_password()
150
+ print(Fore.RED+pw.decode('utf-8')+Style.RESET_ALL)
151
+ except Exception as e:
152
+ print(e)
153
+ elif cmd in ["copy", "c"]:
154
+ try:
155
+ pw: bytes = account.get_active_password()
156
+ pyperclip.copy(pw.decode('utf-8'))
157
+ except Exception as e:
158
+ print(e)
159
+ elif cmd in ["qrcode", "q"]:
160
+ try:
161
+ pw: bytes = account.get_active_password()
162
+ self.print_qrcode(pw)
163
+ except Exception as e:
164
+ print(e)
165
+ elif cmd in ["addpw", 'a']:
166
+ new_password: str = getpass.getpass(f'Enter password for account, {accname}: ')
167
+ if new_password == "":
168
+ return
169
+ account.add_password(new_password)
170
+ account.save()
171
+ elif cmd in ["genpass", "g"]:
172
+ generated_password: str | None = self.generate_password_menu()
173
+ if generated_password:
174
+ account.add_password(generated_password)
175
+ account.save()
176
+ elif cmd in ["test", "t"]:
177
+ pw: bytes = account.get_active_password()
178
+ test: str = getpass.getpass("Type in the password to test: ")
179
+ if pw.decode('utf-8') == test:
180
+ print(Fore.GREEN+"You got it!"+Style.RESET_ALL)
181
+ else:
182
+ print(Fore.RED+"Nope, that's not it."+Style.RESET_ALL)
183
+ elif cmd in ["del", 'd']:
184
+ confirm = pl_prompt('Delete account (yes|no)', 'no')
185
+ if confirm and confirm.lower() in ['yes', 'y'] :
186
+ deleted = account.delete()
187
+ if deleted:
188
+ print(f"Account, {account.account}, deleted.")
189
+ return
190
+ else:
191
+ print(f"Cound not delete {account.account}.")
192
+ elif cmd in ["note", 'n']:
193
+ note = input("Note: ")
194
+ if note and len(note) > 0:
195
+ account.add_note(note)
196
+ account.save()
197
+ elif cmd in ["rename", "r"]:
198
+ new_account_name: str = pl_prompt("New account name", accname)
199
+ if not new_account_name:
200
+ continue
201
+ new_username: str = pl_prompt("New user name", username)
202
+ if not new_username:
203
+ continue
204
+ if new_account_name == account.account and new_username == account.username:
205
+ continue
206
+ account.change_account_name(new_account_name)
207
+ account.change_username(new_username)
208
+ account.save()
209
+ accname = account.account
210
+ username = account.username
211
+ elif cmd in ["pwned", "p"]:
212
+
213
+ if account.type != "password":
214
+ print(Fore.GREEN+"Everything's good."+Style.RESET_ALL)
215
+ else:
216
+ pw: bytes = account.get_active_password()
217
+ password: str = pw.decode('utf-8')
218
+ if self.pl.check_pwnedpasswords(password):
219
+ print(Fore.RED+"PWNED!"+Style.RESET_ALL)
220
+ else:
221
+ print(Fore.GREEN+"Everything's good."+Style.RESET_ALL)
222
+ elif cmd in ["clear"]:
223
+ _ = os.system("clear")
224
+ elif cmd in ['exit', 'e']:
225
+ return
226
+
227
+ def add_account(self):
228
+ try:
229
+ acctype = pl_prompt("Which account type?", "password", ["password", "otp", "totp"])
230
+ if acctype in ["password", "pw", "p"]:
231
+ self.add_password_account()
232
+ elif acctype == "otp":
233
+ self.add_otp_account()
234
+ elif acctype == "totp":
235
+ self.add_totp_account()
236
+ else:
237
+ return
238
+ except Exception as e:
239
+ #traceback.print_exc(file=sys.stdout)
240
+ print(e)
241
+
242
+ def add_password_account(self):
243
+ account_name: str = self.next_word() or pl_prompt("Account name?")
244
+ username: str = self.next_word() or pl_prompt("Username")
245
+
246
+ acc: Account = self.pl.add_account(account_name, username, acc_type='password')
247
+ password: str | None = self.next_word()
248
+ if password:
249
+ if password == '-':
250
+ password = getpass.getpass(f'Enter password for account, {account_name} {username}: ')
251
+ if password == "":
252
+ return
253
+ acc.add_password(password)
254
+ else:
255
+ acc.add_password(password)
256
+ acc.save()
257
+ else:
258
+ self.edit_account(acc)
259
+
260
+ def add_otp_account(self):
261
+ accname: str = self.next_word() or pl_prompt("Account name?")
262
+ username: str = self.next_word() or pl_prompt("Username")
263
+ acc: Account = self.pl.add_account(accname, username, acc_type='otp')
264
+ password: str | None = self.next_word()
265
+ if password:
266
+ # there's a decision that is needed here. If we add a batch of OTP passwords to
267
+ # a list of existing passwords, should I keep the active password where it's at
268
+ # or point it to the first item of the added items.
269
+ # For now, I will leave the active password index where it's at.
270
+ pa: int = acc.active
271
+ if pa == 0:
272
+ pa = 1
273
+ while password:
274
+ acc.add_password(password)
275
+ password = self.next_word()
276
+ acc.set_active_password(pa)
277
+ acc.save()
278
+ else:
279
+ self.edit_account(acc)
280
+
281
+ def add_totp_account(self):
282
+ accname = self.next_word() or pl_prompt("Account name?")
283
+ username = self.next_word() or pl_prompt("Username")
284
+ word = self.next_word()
285
+ if word is None:
286
+ epoch_start = pl_prompt_int('Start time (epoch seconds)', 0)
287
+ else:
288
+ epoch_start = int(word)
289
+ word = self.next_word()
290
+ if word is None:
291
+ time_interval = pl_prompt_int('Time interval (seconds)', 30)
292
+ else:
293
+ time_interval = int(word)
294
+ word = self.next_word()
295
+ if word is None:
296
+ num_digits = pl_prompt_int('Number of digits to return', 6)
297
+ else:
298
+ num_digits = int(word)
299
+ word = self.next_word()
300
+ if word is None or word not in ['sha1', 'md5', 'sha256']:
301
+ hash_algorithm = pl_prompt('Which hash algorithm to use', 'sha1', ['sha1', 'md5', 'sha256', 'sha512'])
302
+ else:
303
+ hash_algorithm = word
304
+ hash_algorithm_literal: Literal['sha1', 'sha256', 'sha512', 'md5'] = cast(Literal["sha1", "sha256", "sha512", "md5"], hash_algorithm)
305
+
306
+
307
+ acc: Account = self.pl.add_account(accname, username, acc_type='totp',
308
+ totp_epoch_start=epoch_start, totp_time_interval=time_interval, totp_num_digits=num_digits,
309
+ totp_hash_algorithm=hash_algorithm_literal)
310
+ acc.save()
311
+
312
+ self.edit_account(acc)
313
+
314
+ def del_account(self):
315
+ accname = self.next_word() or pl_prompt("Account name?")
316
+ username = self.next_word() or pl_prompt("Username")
317
+ confirm = self.next_word()
318
+ if confirm is None:
319
+ confirm = pl_prompt('Delete account (yes|no)', 'no')
320
+ if confirm and confirm.lower() == 'yes':
321
+ acc = self.pl.get_account(accname, username)
322
+ deleted = acc.delete()
323
+ if deleted:
324
+ print("Account, {accname}, deleted.".format(accname=accname))
325
+ else:
326
+ print("Cound not delete {accname}".format(accname=accname))
327
+
328
+ def get_master_password(self, prompt: str) -> bytes:
329
+ return getpass.getpass(prompt=prompt).encode('UTF-8')
330
+
331
+ def print_qrcode(self, data: str) -> None:
332
+ qr = QRCode()
333
+ qr.add_data(data)
334
+ f: StringIO = io.StringIO()
335
+ qr.print_ascii(out=f, invert=True)
336
+ _ = f.seek(0)
337
+ print(f.read())
338
+
339
+ def generate_password_menu(self) -> str | None:
340
+ gentype: str = pl_prompt("Password type", "memorable", ["memorable", "random", "numbers"])
341
+ length: int = pl_prompt_int("Length", 12)
342
+ count: int = pl_prompt_int("How many passwords?", 1)
343
+ while True:
344
+ passwords: list[str] = []
345
+ for idx in range(count):
346
+ if gentype == "memorable":
347
+ password = self.generate_memorable(length)
348
+ elif gentype == "random":
349
+ password = self.generate_random(length)
350
+ elif gentype == "numbers":
351
+ password = self.generate_numbers(length)
352
+ else:
353
+ raise Exception(f"Unknown password generation algorithm: {gentype}")
354
+ passwords.append(password)
355
+ print(f"{idx}\t{password}")
356
+ option: str = pl_prompt("Select a password to add. [q] to cancel without adding. Any other character regenerates the list.")
357
+ if option == 'q':
358
+ return None
359
+ if option == 'm':
360
+ continue
361
+ if option.isnumeric():
362
+ idx = int(option)
363
+ if idx > len(passwords):
364
+ print("You selected an invalid index.")
365
+ else:
366
+ return passwords[idx]
367
+
368
+ def generate_memorable(self, length: int) -> str:
369
+ if length <= 8:
370
+ maxnum = 100
371
+ special = 0
372
+ elif length <= 16:
373
+ maxnum = 1000
374
+ special = 1
375
+ elif length <= 24:
376
+ maxnum = 10000
377
+ special = 2
378
+ else:
379
+ maxnum = 100000
380
+ special = 3
381
+
382
+ random_int: int = secrets.randbelow(maxnum)
383
+ random_special: LiteralString = ''.join(secrets.choice(['!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '-', '_', '+', '=', '{', '}', '[', ']', ':', ';', '<', '>', '.', ',', '?', '/', '~', '`']) for i in range(special))
384
+
385
+ secret_sauce: str = '%d%s' % (random_int, random_special)
386
+ password: str = ""
387
+
388
+ with open('/usr/share/dict/words') as f:
389
+ words: list[str] = [word.strip() for word in f]
390
+ genlen = 0
391
+ while genlen != length:
392
+ password = secret_sauce.join(secrets.choice(words) for i in range(2))
393
+ genlen: int = len(password)
394
+
395
+ return password
396
+
397
+ def generate_random(self, length:int) -> str:
398
+ return secrets.token_urlsafe(32)[0:length]
399
+
400
+ def generate_numbers(self, length:int) -> str:
401
+ return ''.join([str(secrets.choice(range(10))) for _ in range(length)])
402
+
403
+ def check_all_passwords(self) -> None:
404
+ idx = 0
405
+ recs: list[Account] = []
406
+ for acc in self.pl.accounts():
407
+ account_name = acc.account
408
+ user_name = acc.username
409
+ # be very careful here, if you did this to OTP accounts, it would mess up the current index
410
+ # this is meaningless for TOTP accounts, so skip those too
411
+ if acc.type != "password":
412
+ continue
413
+ pw: bytes = acc.get_active_password()
414
+ # check if this "password" is a pin number
415
+ pin_check: str = pw.decode('utf-8')
416
+ if pin_check.isnumeric() and len(pin_check) < 8:
417
+ continue
418
+ if self.pl.check_pwnedpasswords(pw):
419
+ print(f"{idx} {account_name} {user_name} {Fore.RED}PWNED!{Style.RESET_ALL} {pw}")
420
+ recs.append(acc)
421
+ idx += 1
422
+
423
+ idx: int = pl_prompt_int("entry #", 0)
424
+ acc: Account = recs[idx]
425
+ self.edit_account(acc)
426
+
427
+ def main() -> None:
428
+ cui: CUI = CUI()
429
+ while cui.menu():
430
+ pass