Skip to content
Build a CLI Password Manager with Python and AES Encryption (Step by Step)

Build a CLI Password Manager with Python and AES Encryption (Step by Step)

DodaTech Updated Jun 20, 2026 10 min read

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 pyperclip

Project 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 handling

Step 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 key

Expected 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 results

Step 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 1

Architecture


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

How do I sync passwords across devices?
Store the SQLite file in a Syncthing folder, Dropbox, or a private Git repository. Encrypt it with a unique key before syncing for defense-in-depth. Never store the master.json file (salt) in a shared location — keep it only on trusted devices.
Can I export passwords to CSV?
Add a pwm export command. Decrypt each entry with the master password and write service, username, and password to a CSV file. Warn the user about the security implications. Always clear the output file securely after use.
How do I update a password for an existing service?
Use pwm add <service> again — it inserts a new row. The get command returns the most recent entry (ORDER BY updated_at DESC). To fully replace, add a pwm update command that deletes the old entry and inserts the new one atomically.

Next Steps

  • Add TOTP support with the pyotp library
  • Explore SQLite advanced features (WAL mode, encryption)
  • Add REST API integration for browser extension companion
  • Check the Python cryptography tutorial for deeper encryption patterns
  • Try the CLI Tool project for more argparse patterns

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro