Security-focused Password Manager
Posted: Wed Oct 30, 2024 6:41 am
This Python script serves as a minimal, security-focused password manager alternative for those seeking to store passwords locally with the utmost security and stealth.
Features:
Installation and Usage:
One must have Python 3 installed. Adhere to your operating system's instructions.
Subsequently, the requirements are:
Python Script:
Features:
- No internet connection is ever utilised.
- The password database does not contain a file signature, and if deleted, it would be irretrievable by forensic tools.
- Minimal interface.
- Utilises a simple, NoSQL database to store passwords. Passwords are encrypted.
Installation and Usage:
One must have Python 3 installed. Adhere to your operating system's instructions.
Subsequently, the requirements are:
- pyperclip
- pycryptodome
Python Script:
Code: Select all
from Crypto.Cipher import ChaCha20_Poly1305
from Crypto.Random import get_random_bytes
import secrets
import pyperclip
import hashlib
import string
import json
import re
import sys
def input_hidden_unix(label: str = "", inp: str = "", end:str = "\n") -> str:
"""Retrieves input without echo on Unix-like systems."""
import tty, termios
save = termios.tcgetattr(sys.stdin.fileno())
sys.stdout.write(label)
sys.stdout.flush()
while True:
tty.setraw(sys.stdin.fileno())
ch = sys.stdin.read(1)
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, save)
if ord(ch) in [3, 4]:
raise KeyboardInterrupt
if ord(ch) == 0xd:
sys.stdout.write(end)
return inp
if ord(ch) == 8:
inp = inp[:-1]
else:
inp += ch
def input_hidden(label: str = "", inp: str = "", end: str = "\n") -> str:
"""Generic wrapper"""
if sys.platform.startswith("linux"):
return input_hidden_unix(label=label, inp=inp, end=end)
else:
return input(label)
def chacha20_crypt(plaintext: bytes, key: bytes) -> (bytes, bytes, bytes):
"""
Generate a random nonce and encrypt using ChaCha20.
This uses ChaCha20Poly1305 due to the small nonce size of ChaCha20
and thus the same key can be re-used many more times safely.
"""
nonce = get_random_bytes(24)
cipher = ChaCha20_Poly1305.new(key=key, nonce=nonce)
return (*cipher.encrypt_and_digest(plaintext), nonce)
def chacha20_decrypt(ciphertext: bytes, key: bytes, tag: bytes, nonce: bytes) -> bytes:
"""
Decrypts using ChaCha20.
This uses ChaCha20Poly1305 due to the small nonce size of ChaCha20
and thus the same key can be re-used many more times safely.
"""
return ChaCha20_Poly1305.new(key=key, nonce=nonce).decrypt_and_verify(ciphertext, tag)
def scrypt_derive_password(password: str, salt: bytes=get_random_bytes(32), n=2**14, r=15, p=1, maxmem=0, dklen=32) -> (bytes, bytes):
"""Uses scrypt key derivation algorithm to retrieve a key from a password string."""
return hashlib.scrypt(password.encode('utf8'), salt=salt, n=n, r=r, p=p, maxmem=maxmem, dklen=dklen), salt
def check_Password_Secure_Enough(password: str) -> bool:
"""Checks the password for bare minimum strength requirements"""
# We check if the password contain uppercase characters.
password_uppercase = re.search(r"[A-Z]", password) is None
# And check if it contain lowercase characters.
password_lowercase = re.search(r"[a-z]", password) is None
# We then check to see if it contains digits.
password_digits = re.search(r"\d", password) is None
# And we also check if it contains special characters.
password_special = re.search(f"[ {string.punctuation}]", password) is None
# Finally check if the password length is less than 8 characters
password_length = len(password) < 8
return not (password_uppercase or password_lowercase or password_digits or password_special or password_length)
def choose_db_Password() -> str:
"""Asks the user for a password, confirms it and tests the password entropy."""
while True:
db_password = input_hidden("Choose a complex password: ")
if not check_Password_Secure_Enough(db_password):
print("The password you choose is weak!")
print("Please choose a password with at least 8 characters that has lowercase and uppercase letters, digits, and special characters.", end="\n\n")
continue
password_confirm = input_hidden("Confirm password: ")
if password_confirm != db_password:
print("The password you enter did not match! Please try again.", end="\n\n")
continue
return db_password
def ask_db_Password()-> str:
"""Asks the user for a password without confirming it nor testing entropy."""
while True:
db_password = input_hidden("Enter password: ")
# Prompt again if no password was entered
if not db_password.strip():
continue
return db_password
def encrypt(content: bytes, password: str) -> bytes:
"""Handles the process of encrypting the database."""
key_derived, salt = scrypt_derive_password(password)
ciphertext, tag, nonce = chacha20_crypt(content, key_derived)
return salt + ciphertext + tag + nonce
def decrypt(content: bytes, password: str, salt: bytes) -> bytes:
"""Handles the process of decrypting the database."""
key_derived, salt = scrypt_derive_password(password, salt=salt)
nonce = content[ len(content) - 24:]
tag = content[ len(content) - (16 + 24): len(content) - 24]
content = content[:len(content) - (16 + 24)]
return chacha20_decrypt(content, key_derived, tag, nonce)
def update_db(db: dict, db_password: str, db_path: str) -> None:
"""Updates the database - creating it if it doesn't already exist."""
with open(db_path, "wb+") as f:
content = json.dumps(db).encode('utf8')
content = encrypt(content, db_password)
f.write(content)
def load_db(db_path: str) -> (dict, str, str):
"""Loads password database (or creates it if it does not exist and returns decrypted content."""
if not db_path:
raise PermissionError("You did not enter a valid path")
try:
# We do not use `with open` here to make sure the variables are in scope. Python can be unpredictable at times.
db_file = open(db_path, "rb")
db_password = ask_db_Password()
db_content = db_file.read()
db_content = json.loads(decrypt(db_content[32:], db_password, db_content[:32]).decode('utf8'))
db_file.close()
except FileNotFoundError:
db_password = choose_db_Password()
db_content = {"next": 1}
update_db(db_content, db_password, db_path)
return db_content, db_password, db_path
def parse_cmd(l: list, c: str, o: int) -> bool:
"""Parses the command-line interface"""
return c and len([i for i in l if c.split()[0].lower() == i.lower()]) > 0 and len(c.split()) == o
def get_entry(s: str, db_content: dict):
"""Searches the database for a given entry name - or index."""
try:
try:
if str(int(s)) in db_content:
return s
except (KeyError, ValueError):
for i, v in enumerate(db_content):
if i == 0:
continue
if db_content[v]['name'].lower() == s.lower():
return v
raise KeyError()
except KeyError:
return None
def generate_password(length: int=32, lowercase: bool=True, uppercase: bool=True, digits: bool=True, special: bool=True) -> str:
"""Generates a secure password."""
if (not (lowercase or uppercase or special or digits)) or length <= 0:
raise ValueError("Cannot generate an empty password!")
characters = ''
if lowercase:
characters += string.ascii_lowercase
if uppercase:
characters += string.ascii_uppercase
if digits:
characters += string.digits
if special:
characters += string.punctuation
return ''.join(secrets.choice(characters) for i in range(length))
def main() -> None:
"""The main function of the program."""
try:
try:
if len(sys.argv) == 2:
db_path = sys.argv[1].strip()
else:
db_path = input("Enter the path to database file (if it doesn't exist, it will be created): ").strip()
db_content, db_password, db_path = load_db(db_path)
except ValueError:
print("The password you entered was incorrect, or the file is corrupted.")
sys.exit(1)
except (IsADirectoryError, PermissionError):
print("The path you've chosen is invalid! Please pick another one.", end="\n\n")
sys.exit(1)
print('\nType `?` or `help` for help')
while True:
inp = input("command => ").strip()
if parse_cmd(['add', 'new', 'create'], inp, 1):
name = input("Entry name (required): ").strip()
if not name:
print("You did not enter an entry name.")
continue
username = input("Username (optional): ")
password = input("Password (Leave empty to generate one): ")
if not password:
try:
password_length = int(input("Password length (default 32 characters): ").strip())
if password_length == 0:
raise ValueError
except ValueError:
password_length = 32
password_has_special = input("Password has special characters ? [Y/n]: ").strip().lower()
password_has_digits = input("Password has digits ? [Y/n]: ").strip().lower()
password_has_uppercase = input("Password has upper-case letters ? [Y/n]: ").strip().lower()
password_has_lowercase = input("Password has lower-case letters ? [Y/n]: ").strip().lower()
password_has_special = password_has_special == "y"
password_has_digits = password_has_digits == "y"
password_has_uppercase = password_has_uppercase == "y"
password_has_lowercase = password_has_lowercase == "y"
password = generate_password(
length = password_length,
lowercase = bool(password_has_lowercase),
uppercase = bool(password_has_uppercase),
digits = bool(password_has_digits),
special = bool(password_has_special)
)
notes = input("Notes (optional): ")
db_content[str(db_content['next'])] = {
"name": name,
"username": username,
"password": password,
"notes": notes
}
db_content['next'] += 1
update_db(db_content, db_password, db_path)
elif parse_cmd(['passwords', '!', 'list', 'show', 'all'], inp, 1):
if len(db_content) == 1:
print("This database contains no password entries yet.")
else:
for i, v in enumerate(db_content):
if i == 0:
continue
name = db_content[v]['name']
username = db_content[v]['username']
password = db_content[v]['password']
notes = db_content[v]['notes']
print(f"\n{'Entry name' + ' ':<11}: {name}")
print(f"{'Username' + ' ':<11}: {username}" if username else '', end='\n' if username else '')
print(f"{'Password' + ' ':<11}: {password}" if password else '', end='\n' if password else '')
print(f"{'Notes' + ' ':<11}: {notes}" if notes else '', end='\n' if notes else '')
print("\n")
elif parse_cmd(['delete', 'del', 'rm', 'remove', 'erase'], inp, 2):
entry = inp.split()[1]
entry = get_entry(entry, db_content)
if not entry:
print("No entry match found to be deleted.")
continue
print(f"Deleted {db_content[entry]['name']}")
del db_content[entry]
update_db(db_content, db_password, db_path)
elif parse_cmd(['copy', '*****', 'password', 'pwd', 'pass', 'get', 'clip', 'clipboard'], inp, 2):
entry = inp.split()[1]
entry = get_entry(entry, db_content)
if not entry:
print("No entry match found to be copied to the clipboard.")
continue
print("Copied to the clipboard.", db_content[entry])
pyperclip.copy(db_content[entry]['password'])
elif parse_cmd(['?', 'help', 'commands'], inp, 1):
print(f'''\n[1] `add` creates a new password entry.
[2] `delete` [entry name]` deletes the specified password entry.
[3] `show` shows all entry informations currently stored.
[4] `copy` [enrty name]` copies the entry password into your clipboard.
[5] `exit` exits se*****ass cleanly.\n''')
elif parse_cmd(['q', 'quit', 'exit', 'leave', 'close'], inp, 1):
break
if __name__ == "__main__":
main()