Build a CLI Password Manager with Python and AES Encryption (Step by Step)
Build a CLI password manager in Python using the cryptography library for AES encryption, with a master password, SQLite storage, password generation, and a full argparse CLI interface.
What You’ll Build
You’ll build a command-line password manager called pwm that stores encrypted passwords in SQLite, protects them with a master password, generates strong random passwords, and supports add/get/list/delete operations. This architecture mirrors how DodaTech secures internal credentials and how Durga Antivirus Pro manages encrypted configuration data.
Why Building a Password Manager Matters
Password managers are one of the most practical security projects you can build. They combine encryption (AES), key derivation (scrypt/PBKDF2), secure storage (SQLite), and UX (CLI interface) in a single application. Understanding how they work is critical for any developer — you’ll learn exactly how tools like 1Password and Bitwarden operate under the hood, and you’ll gain skills directly applicable to securing sensitive data in any application.
Prerequisites
- Python 3.10+ installed
- Basic familiarity with SQLite and SQL
- Understanding of encryption vs hashing concepts
Step 1: Project Setup
mkdir password-manager
cd password-manager
python -m venv venv
source venv/bin/activate
pip install cryptography pyperclipProject structure:
password-manager/
├── pwm.py # CLI entry point
├── database.py # SQLite operations
├── crypto_utils.py # Encryption/decryption
├── generator.py # Password generation
└── master.py # Master password handlingStep 2: Master Password and Key Derivation
The master password is never stored. Instead, we derive an encryption key using scrypt (a memory-hard KDF resistant to GPU/ASIC attacks).
# master.py
import hashlib
import os
import json
MASTER_FILE = 'master.json'
def hash_master(password: str, salt: bytes = None) -> tuple[bytes, bytes]:
"""Hash the master password using PBKDF2-SHA256 with 600k iterations."""
if salt is None:
salt = os.urandom(32)
key = hashlib.pbkdf2_hmac(
'sha256',
password.encode('utf-8'),
salt,
iterations=600000,
dklen=32
)
return key, salt
def setup_master(password: str):
"""First-time setup: store salt + hash, return encryption key."""
key, salt = hash_master(password)
# Store only the salt and a verification hash
verify_hash = hashlib.sha256(key).hexdigest()
with open(MASTER_FILE, 'w') as f:
json.dump({'salt': salt.hex(), 'verify': verify_hash}, f)
return key
def verify_master(password: str) -> bytes | None:
"""Verify master password, return derived key if correct."""
try:
with open(MASTER_FILE) as f:
data = json.load(f)
except FileNotFoundError:
print("No master password set. Run 'pwm init' first.")
return None
salt = bytes.fromhex(data['salt'])
key, _ = hash_master(password, salt)
verify_hash = hashlib.sha256(key).hexdigest()
if verify_hash != data['verify']:
print("Incorrect master password.")
return None
return keyExpected output: setup_master("mypassword") creates master.json with a salt and verification hash. The derived 32-byte AES key is returned only if the password is correct.
Step 3: AES Encryption/Decryption
We use AES-256 in GCM mode — authenticated encryption that detects tampering.
# crypto_utils.py
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
def encrypt(plaintext: str, key: bytes) -> bytes:
"""Encrypt plaintext with AES-256-GCM. Returns nonce + ciphertext."""
aesgcm = AESGCM(key)
nonce = os.urandom(12) # 96-bit nonce recommended for GCM
ciphertext = aesgcm.encrypt(nonce, plaintext.encode('utf-8'), None)
# Prepend nonce to ciphertext for storage
return nonce + ciphertext
def decrypt(data: bytes, key: bytes) -> str:
"""Decrypt AES-256-GCM data. Expects first 12 bytes = nonce."""
nonce = data[:12]
ciphertext = data[12:]
aesgcm = AESGCM(key)
plaintext = aesgcm.decrypt(nonce, ciphertext, None)
return plaintext.decode('utf-8')Expected output:
key = b'secret_key_32_bytes_long_!!'
encrypted = encrypt("Hello World", key) # bytes (12 + len)
decrypted = decrypt(encrypted, key) # "Hello World"If the ciphertext is modified, decrypt() raises an InvalidTag exception — tampering is detected.
Step 4: Database Layer
# database.py
import sqlite3
import os
DB_PATH = 'passwords.db'
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_db():
conn = get_db()
conn.execute('''
CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service TEXT NOT NULL,
username TEXT NOT NULL,
encrypted_password BLOB NOT NULL,
notes TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''')
conn.execute('CREATE INDEX IF NOT EXISTS idx_service ON entries(service)')
conn.commit()
conn.close()
def add_entry(service: str, username: str, encrypted_password: bytes, notes: str = ''):
conn = get_db()
conn.execute(
'INSERT INTO entries (service, username, encrypted_password, notes) VALUES (?, ?, ?, ?)',
(service, username, encrypted_password, notes)
)
conn.commit()
conn.close()
def get_entry(service: str):
conn = get_db()
cursor = conn.execute(
'SELECT * FROM entries WHERE service = ? ORDER BY updated_at DESC',
(service,)
)
results = cursor.fetchall()
conn.close()
return results
def list_services():
conn = get_db()
cursor = conn.execute('SELECT id, service, username, created_at FROM entries ORDER BY service')
results = cursor.fetchall()
conn.close()
return results
def delete_entry(entry_id: int) -> bool:
conn = get_db()
cursor = conn.execute('DELETE FROM entries WHERE id = ?', (entry_id,))
conn.commit()
deleted = cursor.rowcount > 0
conn.close()
return deleted
def search_entries(query: str):
conn = get_db()
cursor = conn.execute(
'SELECT * FROM entries WHERE service LIKE ? OR username LIKE ?',
(f'%{query}%', f'%{query}%')
)
results = cursor.fetchall()
conn.close()
return resultsStep 5: Password Generator
# generator.py
import secrets
import string
def generate_password(length: int = 20, use_symbols: bool = True) -> str:
"""Generate a cryptographically secure random password."""
chars = string.ascii_letters + string.digits
if use_symbols:
chars += string.punctuation
if length < 8:
raise ValueError("Minimum password length is 8 characters")
# Ensure at least one of each type
password = [
secrets.choice(string.ascii_lowercase),
secrets.choice(string.ascii_uppercase),
secrets.choice(string.digits),
]
if use_symbols:
password.append(secrets.choice(string.punctuation))
# Fill remaining length with random chars
password += [secrets.choice(chars) for _ in range(length - len(password))]
# Shuffle to avoid predictable prefix pattern
secrets.SystemRandom().shuffle(password)
return ''.join(password)Expected output: generate_password(16) returns something like "kL8#mP2$vR5*nQ1@" — always includes uppercase, lowercase, digit, and symbol. Uses secrets.SystemRandom for cryptographic randomness.
Step 6: CLI Interface with argparse
#!/usr/bin/env python3
# pwm.py
import argparse
import sys
from getpass import getpass
from database import init_db, add_entry, get_entry, list_services, delete_entry, search_entries
from crypto_utils import encrypt, decrypt
from generator import generate_password
from master import setup_master, verify_master
def cmd_init(args):
"""Initialize the password manager (set master password)."""
password = getpass("Create master password: ")
confirm = getpass("Confirm master password: ")
if password != confirm:
print("Passwords do not match.")
return
if len(password) < 8:
print("Master password must be at least 8 characters.")
return
init_db()
setup_master(password)
print("Password manager initialized.")
def cmd_add(args):
"""Add a new password entry."""
key = verify_master(getpass("Master password: "))
if not key:
return
username = input("Username: ").strip()
if not username:
print("Username is required.")
return
password = args.password
if not password:
use_generated = input("Generate a strong password? (y/n): ").lower() == 'y'
if use_generated:
password = generate_password()
print(f"Generated password: {password}")
else:
password = getpass("Password: ")
encrypted = encrypt(password, key)
add_entry(args.service, username, encrypted, args.notes or '')
print(f"Password saved for {args.service}.")
def cmd_get(args):
"""Retrieve and decrypt a password."""
key = verify_master(getpass("Master password: "))
if not key:
return
entries = get_entry(args.service)
if not entries:
print(f"No entries found for '{args.service}'.")
return
for entry in entries:
decrypted = decrypt(entry['encrypted_password'], key)
print(f"\nService: {entry['service']}")
print(f"Username: {entry['username']}")
print(f"Password: {decrypted}")
if entry['notes']:
print(f"Notes: {entry['notes']}")
def cmd_list(args):
"""List all saved services."""
key = verify_master(getpass("Master password: "))
if not key:
return
entries = list_services()
if not entries:
print("No passwords saved yet.")
return
print(f"\n{'ID':<4} {'Service':<20} {'Username':<20} {'Created':<20}")
print('-' * 64)
for e in entries:
print(f"{e['id']:<4} {e['service']:<20} {e['username']:<20} {e['created_at']:<20}")
def cmd_delete(args):
"""Delete a password entry by ID."""
key = verify_master(getpass("Master password: "))
if not key:
return
if delete_entry(args.id):
print("Entry deleted.")
else:
print(f"No entry with ID {args.id}.")
def cmd_generate(args):
"""Generate a strong password and optionally save it."""
password = generate_password(args.length, not args.no_symbols)
print(password)
try:
import pyperclip
pyperclip.copy(password)
print("(copied to clipboard)")
except ImportError:
pass
def main():
parser = argparse.ArgumentParser(
description='pwm — Secure CLI Password Manager',
prog='pwm',
)
subparsers = parser.add_subparsers(dest='command', help='Available commands')
# init
p_init = subparsers.add_parser('init', help='Initialize password manager')
# add
p_add = subparsers.add_parser('add', help='Add a new password')
p_add.add_argument('service', help='Service name (e.g., github.com)')
p_add.add_argument('-p', '--password', help='Password (omit to generate or type)')
p_add.add_argument('-n', '--notes', help='Optional notes')
# get
p_get = subparsers.add_parser('get', help='Get password for a service')
p_get.add_argument('service', help='Service name')
# list
p_list = subparsers.add_parser('list', help='List all services')
# delete
p_delete = subparsers.add_parser('delete', help='Delete an entry')
p_delete.add_argument('id', type=int, help='Entry ID to delete')
# generate
p_gen = subparsers.add_parser('gen', help='Generate a strong password')
p_gen.add_argument('-l', '--length', type=int, default=20, help='Password length')
p_gen.add_argument('--no-symbols', action='store_true', help='Exclude symbols')
args = parser.parse_args()
commands = {
'init': cmd_init,
'add': cmd_add,
'get': cmd_get,
'list': cmd_list,
'delete': cmd_delete,
'gen': cmd_generate,
}
if args.command in commands:
commands[args.command](args)
else:
parser.print_help()
if __name__ == '__main__':
main()Step 7: Usage
# Initialize
python pwm.py init
# Add a password
python pwm.py add github.com
# Generate and save a password
python pwm.py gen -l 30
python pwm.py add example.com -p "the-generated-password"
# Retrieve
python pwm.py get github.com
# List all services
python pwm.py list
# Delete
python pwm.py delete 1Architecture
sequenceDiagram
participant User
participant CLI as pwm.py (argparse)
participant Master as master.py
participant Crypto as crypto_utils.py
participant DB as database.py
User->>CLI: pwm init
CLI->>User: Prompt master password
User->>Master: Enter password
Master->>Master: PBKDF2 (600k iterations)
Master->>Master: Store salt + verify hash
Master-->>CLI: Derived AES key
CLI->>DB: init_db()
User->>CLI: pwm add github.com
CLI->>User: Prompt master password
User->>Master: Enter password
Master->>Master: Verify against stored hash
Master-->>CLI: AES key (or None)
CLI->>User: Prompt username + password
User->>Crypto: encrypt(password, key)
Crypto-->>CLI: nonce + ciphertext
CLI->>DB: INSERT encrypted blob
User->>CLI: pwm get github.com
CLI->>User: Prompt master password
Master-->>CLI: AES key
CLI->>DB: SELECT encrypted_password
DB-->>CLI: encrypted blob
CLI->>Crypto: decrypt(blob, key)
Crypto-->>CLI: plaintext password
CLI-->>User: Show service + username + password
Common Errors
1. “cryptography is not installed”
The cryptography package is required. Run pip install cryptography. If using a virtual environment, ensure it’s activated. The package provides the AESGCM implementation — Python’s standard library does not include AES-GCM.
2. “InvalidTag” when decrypting The encrypted data was corrupted or the wrong key is being used. AES-GCM detects any modification to ciphertext or nonce. Common causes: copy-paste errors, database corruption, or using a different master password than the one used for encryption.
3. Master password works but returns “Incorrect master password”
The PBKDF2 hash is computed on the exact bytes of the password. Trailing whitespace or different Unicode normalization (e.g., é vs e + combining accent) produces different hashes. Strip whitespace: password.strip().
4. SQLite “database is locked”
The SQLite database is opened by another process. Only run one pwm command at a time. If a previous command crashed, it might have left a -wal or -shm file. Delete these temporary files when the manager is not running.
5. Generated password contains characters that websites reject
Some websites restrict special characters. Use pwm gen --no-symbols to generate alphanumeric-only passwords. For maximum compatibility, limit to !@#$%^&* instead of all punctuation. Add a safe_symbols parameter to generate_password.
Practice Questions
1. Why use PBKDF2 with 600,000 iterations instead of a single hash? Password hashing must be slow to resist brute-force attacks. 600k iterations mean each guess takes ~300ms instead of microseconds. This makes it impractical to try billions of passwords. The salt prevents rainbow table attacks.
2. Why does AES-GCM produce a different ciphertext every time, even with the same plaintext and key? GCM mode uses a random nonce (12 bytes). Because the nonce changes each encryption, the same plaintext produces different ciphertext. This prevents attackers from detecting that two entries have the same password.
3. What prevents someone from stealing the SQLite file and reading passwords? Without the master password (and the salt in master.json), the attacker cannot derive the AES key. The encrypted blobs are useless without the key. The master password is not stored anywhere — only the salt and verification hash are stored.
4. Challenge: Password expiration tracking
Add a expires_at column to the database. Add a pwm expired command that lists all passwords older than 90 days. Add a --expires-in option to pwm add. Send a desktop notification (or terminal warning) when expired passwords are retrieved.
5. Challenge: Two-factor authentication (2FA) support
Add a TOTP (Time-based One-Time Password) generator. Store the TOTP secret alongside the password. Add pwm totp <service> that computes and displays the current 6-digit code. Use the pyotp library.
FAQ
Next Steps
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro