├── requirements.txt ├── utils ├── generate.py ├── dbconfig.py ├── add.py ├── retrieve.py └── aesutil.py ├── pm.py ├── README.md └── config.py /requirements.txt: -------------------------------------------------------------------------------- 1 | commonmark==0.9.1 2 | mysql-connector-python==8.0.29 3 | protobuf==3.20.1 4 | pycryptodome==3.14.1 5 | Pygments==2.12.0 6 | pyperclip==1.8.2 7 | rich==12.3.0 8 | -------------------------------------------------------------------------------- /utils/generate.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | 4 | def generatePassword(length): 5 | return ''.join([random.choice(string.ascii_letters + string.digits + string.punctuation ) for n in range(length)]) 6 | 7 | -------------------------------------------------------------------------------- /utils/dbconfig.py: -------------------------------------------------------------------------------- 1 | import mysql.connector 2 | from rich import print as printc 3 | from rich.console import Console 4 | console = Console() 5 | 6 | def dbconfig(): 7 | try: 8 | db = mysql.connector.connect( 9 | host ="localhost", 10 | user ="pm", 11 | passwd ="password" 12 | ) 13 | # printc("[green][+][/green] Connected to db") 14 | except Exception as e: 15 | print("[red][!] An error occurred while trying to connect to the database[/red]") 16 | console.print_exception(show_locals=True) 17 | 18 | return db -------------------------------------------------------------------------------- /utils/add.py: -------------------------------------------------------------------------------- 1 | from utils.dbconfig import dbconfig 2 | import utils.aesutil 3 | from getpass import getpass 4 | 5 | from Crypto.Protocol.KDF import PBKDF2 6 | from Crypto.Hash import SHA512 7 | from Crypto.Random import get_random_bytes 8 | import base64 9 | 10 | from rich import print as printc 11 | from rich.console import Console 12 | 13 | def computeMasterKey(mp,ds): 14 | password = mp.encode() 15 | salt = ds.encode() 16 | key = PBKDF2(password, salt, 32, count=1000000, hmac_hash_module=SHA512) 17 | return key 18 | 19 | 20 | def checkEntry(sitename, siteurl, email, username): 21 | db = dbconfig() 22 | cursor = db.cursor() 23 | query = f"SELECT * FROM pm.entries WHERE sitename = '{sitename}' AND siteurl = '{siteurl}' AND email = '{email}' AND username = '{username}'" 24 | cursor.execute(query) 25 | results = cursor.fetchall() 26 | 27 | if len(results)!=0: 28 | return True 29 | return False 30 | 31 | 32 | def addEntry(mp, ds, sitename, siteurl, email, username): 33 | # Check if the entry already exists 34 | if checkEntry(sitename, siteurl, email, username): 35 | printc("[yellow][-][/yellow] Entry with these details already exists") 36 | return 37 | 38 | # Input Password 39 | password = getpass("Password: ") 40 | 41 | # compute master key 42 | mk = computeMasterKey(mp,ds) 43 | 44 | # encrypt password with mk 45 | encrypted = utils.aesutil.encrypt(key=mk, source=password, keyType="bytes") 46 | 47 | # Add to db 48 | db = dbconfig() 49 | cursor = db.cursor() 50 | query = "INSERT INTO pm.entries (sitename, siteurl, email, username, password) values (%s, %s, %s, %s, %s)" 51 | val = (sitename,siteurl,email,username,encrypted) 52 | cursor.execute(query, val) 53 | db.commit() 54 | 55 | printc("[green][+][/green] Added entry ") 56 | -------------------------------------------------------------------------------- /utils/retrieve.py: -------------------------------------------------------------------------------- 1 | from utils.dbconfig import dbconfig 2 | import utils.aesutil 3 | import pyperclip 4 | 5 | from Crypto.Protocol.KDF import PBKDF2 6 | from Crypto.Hash import SHA512 7 | from Crypto.Random import get_random_bytes 8 | import base64 9 | 10 | from rich import print as printc 11 | from rich.console import Console 12 | from rich.table import Table 13 | 14 | def computeMasterKey(mp,ds): 15 | password = mp.encode() 16 | salt = ds.encode() 17 | key = PBKDF2(password, salt, 32, count=1000000, hmac_hash_module=SHA512) 18 | return key 19 | 20 | def retrieveEntries(mp, ds, search, decryptPassword = False): 21 | db = dbconfig() 22 | cursor = db.cursor() 23 | 24 | query = "" 25 | if len(search)==0: 26 | query = "SELECT * FROM pm.entries" 27 | else: 28 | query = "SELECT * FROM pm.entries WHERE " 29 | for i in search: 30 | query+=f"{i} = '{search[i]}' AND " 31 | query = query[:-5] 32 | 33 | cursor.execute(query) 34 | results = cursor.fetchall() 35 | 36 | if len(results) == 0: 37 | printc("[yellow][-][/yellow] No results for the search") 38 | return 39 | 40 | if (decryptPassword and len(results)>1) or (not decryptPassword): 41 | if decryptPassword: 42 | printc("[yellow][-][/yellow] More than one result found for the search, therefore not extracting the password. Be more specific.") 43 | table = Table(title="Results") 44 | table.add_column("Site Name") 45 | table.add_column("URL",) 46 | table.add_column("Email") 47 | table.add_column("Username") 48 | table.add_column("Password") 49 | 50 | for i in results: 51 | table.add_row(i[0], i[1], i[2], i[3], "{hidden}") 52 | console = Console() 53 | console.print(table) 54 | return 55 | 56 | if decryptPassword and len(results)==1: 57 | # Compute master key 58 | mk = computeMasterKey(mp,ds) 59 | 60 | # decrypt password 61 | decrypted = utils.aesutil.decrypt(key=mk,source=results[0][4],keyType="bytes") 62 | 63 | printc("[green][+][/green] Password copied to clipboard") 64 | pyperclip.copy(decrypted.decode()) 65 | 66 | db.close() 67 | -------------------------------------------------------------------------------- /utils/aesutil.py: -------------------------------------------------------------------------------- 1 | 2 | import base64 3 | from Crypto.Cipher import AES 4 | from Crypto.Hash import SHA256 5 | from Crypto import Random 6 | import sys 7 | 8 | def encrypt(key, source, encode=True, keyType = 'hex'): 9 | ''' 10 | Parameters: 11 | key - The key with which you want to encrypt. You can give a key in hex representation (which will then be converted to bytes) or just a normal ascii string. Default is hex 12 | source - the message to encrypt 13 | encode - whether to encode the output in base64. Default is true 14 | keyType - specify the type of key passed 15 | 16 | Returns: 17 | Base64 encoded cipher 18 | ''' 19 | 20 | source = source.encode() 21 | if keyType == "hex": 22 | # Convert key (in hex representation) to bytes 23 | key = bytes(bytearray.fromhex(key)) 24 | # else: 25 | # # use SHA-256 over our key to get a proper-sized AES key. Outputs in bytes 26 | # key = key.encode() 27 | # key = SHA256.new(key).digest() 28 | 29 | IV = Random.new().read(AES.block_size) # generate IV 30 | encryptor = AES.new(key, AES.MODE_CBC, IV) 31 | padding = AES.block_size - len(source) % AES.block_size # calculate needed padding 32 | source += bytes([padding]) * padding # Python 2.x: source += chr(padding) * padding 33 | data = IV + encryptor.encrypt(source) # store the IV at the beginning and encrypt 34 | return base64.b64encode(data).decode() if encode else data 35 | 36 | 37 | def decrypt(key, source, decode=True,keyType="hex"): 38 | ''' 39 | Parameters: 40 | key - key to decrypt with. It can either be an ascii string or a string in hex representation. Default is hex representation 41 | source - the cipher (or encrypted message) to decrypt 42 | decode - whether to first base64 decode the cipher before trying to decrypt with the key. Default is true 43 | keyType - specify the type of key passed 44 | 45 | Returns: 46 | The decrypted data 47 | ''' 48 | 49 | source = source.encode() 50 | if decode: 51 | source = base64.b64decode(source) 52 | 53 | if keyType == "hex": 54 | # Convert key to bytes 55 | key = bytes(bytearray.fromhex(key)) 56 | # else: 57 | # # use SHA-256 over our key to get a proper-sized AES key 58 | # key = key.encode() 59 | # key = SHA256.new(key).digest() 60 | 61 | IV = source[:AES.block_size] # extract the IV from the beginning 62 | decryptor = AES.new(key, AES.MODE_CBC, IV) 63 | data = decryptor.decrypt(source[AES.block_size:]) # decrypt 64 | padding = data[-1] # pick the padding value from the end; Python 2.x: ord(data[-1]) 65 | if data[-padding:] != bytes([padding]) * padding: # Python 2.x: chr(padding) * padding 66 | raise ValueError("Invalid padding...") 67 | return data[:-padding] # remove the padding 68 | -------------------------------------------------------------------------------- /pm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | from getpass import getpass 5 | import hashlib 6 | import pyperclip 7 | 8 | from rich import print as printc 9 | 10 | import utils.add 11 | import utils.retrieve 12 | import utils.generate 13 | from utils.dbconfig import dbconfig 14 | 15 | parser = argparse.ArgumentParser(description='Description') 16 | 17 | parser.add_argument('option', help='(a)dd / (e)xtract / (g)enerate') 18 | parser.add_argument("-s", "--name", help="Site name") 19 | parser.add_argument("-u", "--url", help="Site URL") 20 | parser.add_argument("-e", "--email", help="Email") 21 | parser.add_argument("-l", "--login", help="Username") 22 | parser.add_argument("--length", help="Length of the password to generate",type=int) 23 | parser.add_argument("-c", "--copy", action='store_true', help='Copy password to clipboard') 24 | 25 | 26 | args = parser.parse_args() 27 | 28 | 29 | def inputAndValidateMasterPassword(): 30 | mp = getpass("MASTER PASSWORD: ") 31 | hashed_mp = hashlib.sha256(mp.encode()).hexdigest() 32 | 33 | db = dbconfig() 34 | cursor = db.cursor() 35 | query = "SELECT * FROM pm.secrets" 36 | cursor.execute(query) 37 | result = cursor.fetchall()[0] 38 | if hashed_mp != result[0]: 39 | printc("[red][!] WRONG! [/red]") 40 | return None 41 | 42 | return [mp,result[1]] 43 | 44 | 45 | def main(): 46 | if args.option in ["add","a"]: 47 | if args.name == None or args.url == None or args.login == None: 48 | if args.name == None: 49 | printc("[red][!][/red] Site Name (-s) required ") 50 | if args.url == None: 51 | printc("[red][!][/red] Site URL (-u) required ") 52 | if args.login == None: 53 | printc("[red][!][/red] Site Login (-l) required ") 54 | return 55 | 56 | if args.email == None: 57 | args.email = "" 58 | 59 | res = inputAndValidateMasterPassword() 60 | if res is not None: 61 | utils.add.addEntry(res[0],res[1],args.name,args.url,args.email,args.login) 62 | 63 | 64 | if args.option in ["extract","e"]: 65 | # if args.name == None and args.url == None and args.email == None and args.login == None: 66 | # # retrieve all 67 | # printc("[red][!][/red] Please enter at least one search field (sitename/url/email/username)") 68 | # return 69 | res = inputAndValidateMasterPassword() 70 | 71 | search = {} 72 | if args.name is not None: 73 | search["sitename"] = args.name 74 | if args.url is not None: 75 | search["siteurl"] = args.url 76 | if args.email is not None: 77 | search["email"] = args.email 78 | if args.login is not None: 79 | search["username"] = args.login 80 | 81 | if res is not None: 82 | utils.retrieve.retrieveEntries(res[0],res[1],search,decryptPassword = args.copy) 83 | 84 | 85 | if args.option in ["generate","g"]: 86 | if args.length == None: 87 | printc("[red][+][/red] Specify length of the password to generate (--length)") 88 | return 89 | password = utils.generate.generatePassword(args.length) 90 | pyperclip.copy(password) 91 | printc("[green][+][/green] Password generated and copied to clipboard") 92 | 93 | 94 | 95 | main() -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Python Password Manager 2 | 3 | A simple local password manager written in Python and MariaDB. Uses [pbkdf2](https://en.wikipedia.org/wiki/PBKDF2) to derive a 256 bit key from a MASTER PASSWORD and DEVICE SECRET to use with AES-256 for encrypting/decrypting. 4 | 5 | 6 | # Installation 7 | You need to have python3 to run this on Windows, Linux or MacOS 8 | ## Linux 9 | ### Install Python Requirements 10 | ``` 11 | sudo apt install python3-pip 12 | pip install -r requirements.txt 13 | ``` 14 | 15 | ### MariaDB 16 | #### Install MariaDB on linux with apt 17 | ``` 18 | sudo apt-key adv --recv-keys --keyserver keyserver.ubuntu.com 0xcbcb082a1bb943db 19 | sudo add-apt-repository 'deb http://ftp.osuosl.org/pub/mariadb/repo/5.5/ubuntuprecise main' 20 | sudo apt-get update 21 | sudo apt-get install mariadb-server 22 | ``` 23 | #### Create user 'pm' and grant permissions 24 | **Login to mysql as root** 25 | 26 | ``` 27 | sudo mysql -u root 28 | ``` 29 | **Create User** 30 | ``` 31 | CREATE USER 'pm'@localhost IDENTIFIED BY 'password'; 32 | ``` 33 | **Grant privileges** 34 | ``` 35 | GRANT ALL PRIVILEGES ON *.* TO 'pm'@localhost IDENTIFIED BY 'password'; 36 | ``` 37 | 38 | ### Pyperclip 39 | [Pyperclip](https://pypi.org/project/pyperclip/) is a python module used to copy data to the clipboard. If you get a "not implemented error", follow this simple fix: https://pyperclip.readthedocs.io/en/latest/index.html#not-implemented-error 40 | 41 | ## Windows 42 | ### Install Python Requirements 43 | ```pip install -r requirements.txt``` 44 | 45 | ### MariaDB 46 | #### Install 47 | Follow [these instructions](https://www.mariadbtutorial.com/getting-started/install-mariadb/) to install MariaDB on Windows 48 | #### Create user and grant privileges 49 | - Navigate to MariaDB bin directory 50 | ``` 51 | C:\Program Files\MariaDB\bin 52 | ``` 53 | - Login as root with the password you chose while installation 54 | ``` 55 | mysql.exe -u root -p 56 | ``` 57 | - Create user 58 | ``` 59 | CREATE USER 'pm'@localhost IDENTIFIED BY 'password'; 60 | ``` 61 | - Grant privileges 62 | ``` 63 | GRANT ALL PRIVILEGES ON *.* TO 'pm'@localhost IDENTIFIED BY 'password'; 64 | ``` 65 | 66 | 67 | ## Run 68 | ### Configure 69 | 70 | You need to first configure the password manager by choosing a MASTER PASSWORD. This config step is only required to be executed once. 71 | ``` 72 | python config.py make 73 | ``` 74 | The above command will make a new configuration by asking you to choose a MASTER PASSWORD. 75 | This will generate the DEVICE SECRET, create db and required tables. 76 | 77 | ``` 78 | python config.py delete 79 | ``` 80 | The above command will delete the existing configuration. Doing this will completely delete your device secret and all your entries and you will loose all your passwords. So be aware! 81 | 82 | ``` 83 | python config.py remake 84 | ``` 85 | The above command will first delete the existing configuration and create a fresh new configuration by asking you to choose a MASTER PASSWORD, generate the DEVICE SECRET, create the db and required tables. 86 | 87 | ### Usage 88 | ``` 89 | python pm.py -h 90 | usage: pm.py [-h] [-s NAME] [-u URL] [-e EMAIL] [-l LOGIN] [--length LENGTH] [-c] option 91 | 92 | Description 93 | 94 | positional arguments: 95 | option (a)dd / (e)xtract / (g)enerate 96 | 97 | optional arguments: 98 | -h, --help show this help message and exit 99 | -s NAME, --name NAME Site name 100 | -u URL, --url URL Site URL 101 | -e EMAIL, --email EMAIL 102 | Email 103 | -l LOGIN, --login LOGIN 104 | Username 105 | --length LENGTH Length of the password to generate 106 | -c, --copy Copy password to clipboard 107 | ``` 108 | 109 | 110 | ### Add entry 111 | ``` 112 | python pm.py add -s mysite -u mysite.com -e hello@email.com -l myusername 113 | ``` 114 | ### Retrieve entry 115 | ``` 116 | python pm.py extract 117 | ``` 118 | The above command retrieves all the entries 119 | ``` 120 | python pm.py e -s mysite 121 | ``` 122 | The above command retrieves all the entries whose site name is "mysite" 123 | ``` 124 | python pm.py e -s mysite -l myusername 125 | ``` 126 | The above command retrieves the entry whose site name is "mysite" and username is "myusername" 127 | ``` 128 | python pm.py e -s mysite -l myusername --copy 129 | ``` 130 | The above command copies the password of the site "mysite" and username "myusername" into the clipboard 131 | ### Generate Password 132 | ``` 133 | python pm.py g --length 15 134 | ``` 135 | The above command generates a password of length 15 and copies to clipboard 136 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import string 4 | import random 5 | import hashlib 6 | import sys 7 | from getpass import getpass 8 | 9 | from utils.dbconfig import dbconfig 10 | 11 | from rich import print as printc 12 | from rich.console import Console 13 | 14 | console = Console() 15 | 16 | def checkConfig(): 17 | db = dbconfig() 18 | cursor = db.cursor() 19 | query = "SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = 'pm'" 20 | cursor.execute(query) 21 | results = cursor.fetchall() 22 | db.close() 23 | if len(results)!=0: 24 | return True 25 | return False 26 | 27 | 28 | def generateDeviceSecret(length=10): 29 | return ''.join(random.choices(string.ascii_uppercase + string.digits, k = length)) 30 | 31 | 32 | def make(): 33 | if checkConfig(): 34 | printc("[red][!] Already Configured! [/red]") 35 | return 36 | 37 | printc("[green][+] Creating new config [/green]") 38 | 39 | # Create database 40 | db = dbconfig() 41 | cursor = db.cursor() 42 | try: 43 | cursor.execute("CREATE DATABASE pm") 44 | except Exception as e: 45 | printc("[red][!] An error occurred while trying to create db. Check if database with name 'pm' already exists - if it does, delete it and try again.") 46 | console.print_exception(show_locals=True) 47 | sys.exit(0) 48 | 49 | printc("[green][+][/green] Database 'pm' created") 50 | 51 | # Create tables 52 | query = "CREATE TABLE pm.secrets (masterkey_hash TEXT NOT NULL, device_secret TEXT NOT NULL)" 53 | res = cursor.execute(query) 54 | printc("[green][+][/green] Table 'secrets' created ") 55 | 56 | query = "CREATE TABLE pm.entries (sitename TEXT NOT NULL, siteurl TEXT NOT NULL, email TEXT, username TEXT, password TEXT NOT NULL)" 57 | res = cursor.execute(query) 58 | printc("[green][+][/green] Table 'entries' created ") 59 | 60 | 61 | mp = "" 62 | printc("[green][+] A [bold]MASTER PASSWORD[/bold] is the only password you will need to remember in-order to access all your other passwords. Choosing a strong [bold]MASTER PASSWORD[/bold] is essential because all your other passwords will be [bold]encrypted[/bold] with a key that is derived from your [bold]MASTER PASSWORD[/bold]. Therefore, please choose a strong one that has upper and lower case characters, numbers and also special characters. Remember your [bold]MASTER PASSWORD[/bold] because it won't be stored anywhere by this program, and you also cannot change it once chosen. [/green]\n") 63 | 64 | while 1: 65 | mp = getpass("Choose a MASTER PASSWORD: ") 66 | if mp == getpass("Re-type: ") and mp!="": 67 | break 68 | printc("[yellow][-] Please try again.[/yellow]") 69 | 70 | # Hash the MASTER PASSWORD 71 | hashed_mp = hashlib.sha256(mp.encode()).hexdigest() 72 | printc("[green][+][/green] Generated hash of MASTER PASSWORD") 73 | 74 | 75 | # Generate a device secret 76 | ds = generateDeviceSecret() 77 | printc("[green][+][/green] Device Secret generated") 78 | 79 | # Add them to db 80 | query = "INSERT INTO pm.secrets (masterkey_hash, device_secret) values (%s, %s)" 81 | val = (hashed_mp, ds) 82 | cursor.execute(query, val) 83 | db.commit() 84 | 85 | printc("[green][+][/green] Added to the database") 86 | 87 | printc("[green][+] Configuration done![/green]") 88 | 89 | db.close() 90 | 91 | 92 | def delete(): 93 | printc("[red][-] Deleting a config clears the device secret and all your entries from the database. This means you will loose access to all your passwords that you have added into the password manager until now. Only do this if you truly want to 'destroy' all your entries. This action cannot be undone. [/red]") 94 | 95 | while 1: 96 | op = input("So are you sure you want to continue? (y/N): ") 97 | if op.upper() == "Y": 98 | break 99 | if op.upper() == "N" or op.upper == "": 100 | sys.exit(0) 101 | else: 102 | continue 103 | 104 | printc("[green][-][/green] Deleting config") 105 | 106 | 107 | if not checkConfig(): 108 | printc("[yellow][-][/yellow] No configuration exists to delete!") 109 | return 110 | 111 | db = dbconfig() 112 | cursor = db.cursor() 113 | query = "DROP DATABASE pm" 114 | cursor.execute(query) 115 | db.commit() 116 | db.close() 117 | printc("[green][+] Config deleted![/green]") 118 | 119 | def remake(): 120 | printc("[green][+][/green] Remaking config") 121 | delete() 122 | make() 123 | 124 | 125 | if __name__ == "__main__": 126 | 127 | if len(sys.argv)!=2: 128 | print("Usage: python config.py ") 129 | sys.exit(0) 130 | 131 | if sys.argv[1] == "make": 132 | make() 133 | elif sys.argv[1] == "delete": 134 | delete() 135 | elif sys.argv[1] == "remake": 136 | remake() 137 | else: 138 | print("Usage: python config.py ") --------------------------------------------------------------------------------