Page 1 of 1

Security-focused Password Manager

Posted: Wed Oct 30, 2024 6:41 am
by TheVikingsofDW
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:
  • 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()

Re: Security-focused Password Manager

Posted: Thu Oct 31, 2024 1:54 pm
by yeyo
Hello, so what are the uses cases for that, its a CLI tool?

Re: Security-focused Password Manager

Posted: Tue Nov 05, 2024 12:31 am
by TheVikingsofDW
yeyo wrote:Hello, so what are the uses cases for that, its a CLI tool?

This tool provides a secure, offline solution for users to manage their passwords, and easily retrieve and use them when needed.

Being a CLI tool, it can be easily used in scripting, automation, or by users who prefer working with command-line interfaces.