تمرین و پروژه‌های پایتون

ساخت برنامه مدیریت رمزهای عبور با پایتون؛ پروژه امن‌تر با Fernet و Master Password

ساخت برنامه مدیریت رمزهای عبور با پایتون

🗓 آخرین به‌روزرسانی:

ساخت برنامه مدیریت رمزهای عبور با پایتون پروژه‌ای جذاب برای تمرین فایل، رمزگذاری، مدیریت داده، جست‌وجو، اعتبارسنجی ورودی و طراحی منوی تعاملی است. اما این پروژه یک تفاوت مهم با برنامه‌های قبلی دارد: اگر رمزها را به‌صورت متن ساده داخل JSON ذخیره کنیم، عملاً یک فایل پر از اطلاعات حساس ساخته‌ایم. به همین دلیل در این آموزش داده‌های Vault را قبل از ذخیره رمزگذاری می‌کنیم و کلید رمزگذاری را مستقیماً روی دیسک نگه نمی‌داریم؛ کلید از یک Master Password مشتق می‌شود.

این مقاله هجدهمین پروژه فاز اول دسته تمرین و پروژه‌های پایتون است. قبلاً در پروژه ساخت تولیدکننده رمز عبور با پایتون یاد گرفتیم برای تولید رمز تصادفی بهتر است از secrets استفاده کنیم. در این پروژه همان ایده را وارد یک Password Manager می‌کنیم و علاوه بر تولید رمز، امکان ثبت حساب، جست‌وجو، مشاهده، ویرایش و حذف را نیز اضافه می‌کنیم.

نکته امنیتی: این پروژه آموزشی است و جایگزین نرم‌افزارهای حرفه‌ای و ممیزی‌شده مدیریت رمز عبور نیست. هدف آن یادگیری طراحی امن‌تر نسبت به ذخیره Plain Text است.

در این پروژه چه چیزی می‌سازیم؟

  • ساخت Vault رمزگذاری‌شده
  • استفاده از Master Password بدون ذخیره مستقیم آن
  • مشتق‌سازی کلید رمزگذاری از Master Password
  • ذخیره Salt تصادفی برای مشتق‌سازی کلید
  • رمزگذاری کل Vault با Fernet
  • ثبت نام سرویس، نام کاربری و رمز عبور
  • تولید رمز قوی با ماژول secrets
  • نمایش فهرست حساب‌ها بدون چاپ خودکار رمز
  • جست‌وجو بر اساس سرویس یا نام کاربری
  • نمایش رمز فقط در صورت درخواست صریح
  • ویرایش اطلاعات حساب
  • حذف حساب
  • مدیریت Master Password اشتباه
  • ذخیره و بازیابی داده‌ها در اجرای بعدی

چرا JSON ساده برای رمزها مناسب نیست؟

در پروژه‌هایی مثل مدیریت هزینه یا دفترچه یادداشت، فایل JSON برای تمرین و ذخیره اطلاعات انتخاب خوبی است. اما رمز عبور ماهیت متفاوتی دارد. اگر چنین ساختاری را بدون رمزگذاری ذخیره کنیم:

{
    "service": "example.com",
    "username": "user@example.com",
    "password": "MySecretPassword"
}

هر کسی که به فایل دسترسی پیدا کند می‌تواند رمز را مستقیماً بخواند. بنابراین در این پروژه JSON فقط در حافظه برنامه به‌صورت قابل خواندن وجود دارد و هنگام ذخیره، کل محتوای آن رمزگذاری می‌شود. برای مرور ساختار JSON می‌توانید مقاله کار با فایل‌های JSON در پایتون را مطالعه کنید.

ابزارهای مورد نیاز

بخش‌های عمومی پروژه از کتابخانه استاندارد پایتون استفاده می‌کنند، اما برای رمزگذاری به پکیج cryptography نیاز داریم:

pip install cryptography

سپس فایل پروژه را بسازید:

password_manager.py

برنامه دو فایل ایجاد می‌کند:

vault.dat
vault.salt

vault.dat داده رمزگذاری‌شده و vault.salt Salt مورد نیاز برای مشتق‌سازی دوباره کلید است. Salt محرمانه نیست و باید بین اجراها ثابت بماند.

مرحله اول: Importها و تنظیمات

import base64
import json
import os
import secrets
import string
from getpass import getpass
from pathlib import Path

from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id

VAULT_FILE = Path("vault.dat")
SALT_FILE = Path("vault.salt")

از getpass() استفاده می‌کنیم تا Master Password هنگام تایپ روی ترمینال نمایش داده نشود.

مرحله دوم: ساخت یا خواندن Salt

def get_or_create_salt():
    if SALT_FILE.exists():
        return SALT_FILE.read_bytes()

    salt = os.urandom(16)
    SALT_FILE.write_bytes(salt)
    return salt

Salt تصادفی باعث می‌شود دو کاربر با Master Password یکسان الزاماً کلید مشتق‌شده یکسان نداشته باشند.

مرحله سوم: مشتق‌سازی کلید از Master Password

def derive_key(master_password, salt):
    kdf = Argon2id(
        salt=salt,
        length=32,
        iterations=1,
        lanes=4,
        memory_cost=64 * 1024,
    )

    raw_key = kdf.derive(
        master_password.encode("utf-8")
    )

    return base64.urlsafe_b64encode(raw_key)

Master Password مستقیماً به Fernet داده نمی‌شود. ابتدا یک KDF از روی آن یک کلید ۳۲ بایتی تولید می‌کند. Salt روی دیسک ذخیره می‌شود، اما Master Password و کلید مشتق‌شده ذخیره نمی‌شوند.

مرحله چهارم: ساخت شیء Fernet

def create_fernet(master_password):
    salt = get_or_create_salt()
    key = derive_key(
        master_password,
        salt,
    )
    return Fernet(key)

مرحله پنجم: بارگذاری Vault

def load_vault(fernet):
    if not VAULT_FILE.exists():
        return []

    encrypted = VAULT_FILE.read_bytes()

    try:
        decrypted = fernet.decrypt(encrypted)
    except InvalidToken:
        raise ValueError(
            "Master Password اشتباه است "
            "یا فایل Vault آسیب دیده است."
        )

    try:
        data = json.loads(
            decrypted.decode("utf-8")
        )
    except (
        UnicodeDecodeError,
        json.JSONDecodeError,
    ) as error:
        raise ValueError(
            "محتوای Vault معتبر نیست."
        ) from error

    return data if isinstance(data, list) else []

اگر Master Password اشتباه باشد، کلید صحیح ساخته نمی‌شود و Fernet نمی‌تواند داده را معتبر Decrypt کند.

مرحله ششم: ذخیره Vault به‌صورت رمزگذاری‌شده

def save_vault(entries, fernet):
    raw = json.dumps(
        entries,
        ensure_ascii=False,
        indent=2,
    ).encode("utf-8")

    encrypted = fernet.encrypt(raw)
    VAULT_FILE.write_bytes(encrypted)

در این مرحله JSON ابتدا در حافظه ساخته می‌شود، سپس کل داده یک‌جا رمزگذاری شده و فقط Ciphertext داخل فایل نوشته می‌شود.

مرحله هفتم: تولید رمز قوی

def generate_password(length=18):
    if length < 12:
        length = 12

    alphabet = (
        string.ascii_letters
        + string.digits
        + "!@#$%^&*_-+="
    )

    return "".join(
        secrets.choice(alphabet)
        for _ in range(length)
    )

برای تولید رمز از secrets استفاده می‌کنیم، نه random. مقاله تولیدکننده رمز عبور سایت جزئیات بیشتری درباره این تفاوت و ساخت Password Generator دارد.

مرحله هشتم: ساخت شناسه یکتا

def next_entry_id(entries):
    return max(
        (
            entry.get("id", 0)
            for entry in entries
        ),
        default=0,
    ) + 1

مرحله نهم: افزودن حساب جدید

def add_entry(entries, fernet):
    service = input(
        "نام سرویس: "
    ).strip()

    username = input(
        "نام کاربری یا ایمیل: "
    ).strip()

    if not service or not username:
        print(
            "سرویس و نام کاربری الزامی هستند."
        )
        return

    choice = input(
        "رمز خودکار ساخته شود؟ (y/n): "
    ).strip().lower()

    if choice == "y":
        password = generate_password()
        print(
            "رمز تولید شد. آن را بعد از ذخیره "
            "از بخش نمایش رمز مشاهده کنید."
        )
    else:
        password = getpass(
            "رمز عبور: "
        )

    if not password:
        print("رمز نمی‌تواند خالی باشد.")
        return

    entries.append({
        "id": next_entry_id(entries),
        "service": service,
        "username": username,
        "password": password,
    })

    save_vault(entries, fernet)
    print("حساب ذخیره شد.")

مرحله دهم: نمایش حساب‌ها بدون نمایش رمز

def list_entries(entries):
    if not entries:
        print("Vault خالی است.")
        return

    print("\n=== حساب‌ها ===")

    for entry in entries:
        print(
            f"#{entry['id']} | "
            f"{entry['service']} | "
            f"{entry['username']}"
        )

یکی از تصمیم‌های مهم طراحی این است که رمزها در لیست عمومی چاپ نشوند. کاربر برای مشاهده رمز باید عملیات جداگانه‌ای انجام دهد.

مرحله یازدهم: پیدا کردن حساب با ID

def find_entry_by_id(entries, entry_id):
    for entry in entries:
        if entry.get("id") == entry_id:
            return entry

    return None

مرحله دوازدهم: جست‌وجوی حساب

def search_entries(entries):
    query = input(
        "سرویس یا نام کاربری: "
    ).strip().casefold()

    if not query:
        return

    rows = [
        entry
        for entry in entries
        if query
        in entry.get(
            "service",
            "",
        ).casefold()
        or query
        in entry.get(
            "username",
            "",
        ).casefold()
    ]

    list_entries(rows)

مرحله سیزدهم: نمایش رمز یک حساب

def reveal_password(entries):
    value = input(
        "شناسه حساب: "
    ).strip()

    try:
        entry_id = int(value)
    except ValueError:
        print("شناسه نامعتبر است.")
        return

    entry = find_entry_by_id(
        entries,
        entry_id,
    )

    if entry is None:
        print("حساب پیدا نشد.")
        return

    print(
        f"سرویس: {entry['service']}"
    )
    print(
        f"نام کاربری: {entry['username']}"
    )
    print(
        f"رمز: {entry['password']}"
    )

چاپ رمز در ترمینال همچنان می‌تواند از نظر Privacy ریسک داشته باشد؛ این قابلیت فقط برای کامل بودن پروژه آموزشی اضافه شده است.

مرحله چهاردهم: ویرایش حساب

def edit_entry(entries, fernet):
    value = input(
        "شناسه حساب: "
    ).strip()

    try:
        entry_id = int(value)
    except ValueError:
        print("شناسه نامعتبر است.")
        return

    entry = find_entry_by_id(
        entries,
        entry_id,
    )

    if entry is None:
        print("حساب پیدا نشد.")
        return

    service = input(
        f"سرویس [{entry['service']}]: "
    ).strip()

    username = input(
        f"نام کاربری [{entry['username']}]: "
    ).strip()

    change_password = input(
        "رمز تغییر کند؟ (y/n): "
    ).strip().lower()

    if service:
        entry["service"] = service

    if username:
        entry["username"] = username

    if change_password == "y":
        new_password = getpass(
            "رمز جدید: "
        )

        if new_password:
            entry["password"] = new_password

    save_vault(entries, fernet)
    print("حساب ویرایش شد.")

مرحله پانزدهم: حذف حساب

def delete_entry(entries, fernet):
    value = input(
        "شناسه حساب: "
    ).strip()

    try:
        entry_id = int(value)
    except ValueError:
        print("شناسه نامعتبر است.")
        return

    entry = find_entry_by_id(
        entries,
        entry_id,
    )

    if entry is None:
        print("حساب پیدا نشد.")
        return

    confirm = input(
        f"حذف {entry['service']}؟ (y/n): "
    ).strip().lower()

    if confirm != "y":
        print("حذف لغو شد.")
        return

    entries.remove(entry)
    save_vault(entries, fernet)
    print("حساب حذف شد.")

کد کامل برنامه مدیریت رمزهای عبور با پایتون

import base64
import json
import os
import secrets
import string
from getpass import getpass
from pathlib import Path

from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives.kdf.argon2 import Argon2id

VAULT_FILE = Path("vault.dat")
SALT_FILE = Path("vault.salt")


def get_or_create_salt():
    if SALT_FILE.exists():
        return SALT_FILE.read_bytes()

    salt = os.urandom(16)
    SALT_FILE.write_bytes(salt)
    return salt


def derive_key(master_password, salt):
    kdf = Argon2id(
        salt=salt,
        length=32,
        iterations=1,
        lanes=4,
        memory_cost=64 * 1024,
    )
    raw_key = kdf.derive(
        master_password.encode("utf-8")
    )
    return base64.urlsafe_b64encode(raw_key)


def create_fernet(master_password):
    salt = get_or_create_salt()
    return Fernet(
        derive_key(master_password, salt)
    )


def load_vault(fernet):
    if not VAULT_FILE.exists():
        return []

    encrypted = VAULT_FILE.read_bytes()

    try:
        decrypted = fernet.decrypt(encrypted)
    except InvalidToken:
        raise ValueError(
            "Master Password اشتباه است "
            "یا Vault آسیب دیده است."
        )

    data = json.loads(
        decrypted.decode("utf-8")
    )
    return data if isinstance(data, list) else []


def save_vault(entries, fernet):
    raw = json.dumps(
        entries,
        ensure_ascii=False,
        indent=2,
    ).encode("utf-8")

    VAULT_FILE.write_bytes(
        fernet.encrypt(raw)
    )


def generate_password(length=18):
    alphabet = (
        string.ascii_letters
        + string.digits
        + "!@#$%^&*_-+="
    )
    return "".join(
        secrets.choice(alphabet)
        for _ in range(max(length, 12))
    )


def next_entry_id(entries):
    return max(
        (
            entry.get("id", 0)
            for entry in entries
        ),
        default=0,
    ) + 1


def find_entry_by_id(entries, entry_id):
    for entry in entries:
        if entry.get("id") == entry_id:
            return entry
    return None


def list_entries(entries):
    if not entries:
        print("Vault خالی است.")
        return

    print("\n=== حساب‌ها ===")
    for entry in entries:
        print(
            f"#{entry['id']} | "
            f"{entry['service']} | "
            f"{entry['username']}"
        )


def add_entry(entries, fernet):
    service = input("نام سرویس: ").strip()
    username = input(
        "نام کاربری یا ایمیل: "
    ).strip()

    if not service or not username:
        print("اطلاعات ناقص است.")
        return

    auto = input(
        "رمز خودکار؟ (y/n): "
    ).strip().lower()

    password = (
        generate_password()
        if auto == "y"
        else getpass("رمز عبور: ")
    )

    if not password:
        print("رمز خالی است.")
        return

    entries.append({
        "id": next_entry_id(entries),
        "service": service,
        "username": username,
        "password": password,
    })

    save_vault(entries, fernet)
    print("حساب ذخیره شد.")


def search_entries(entries):
    query = input(
        "سرویس یا نام کاربری: "
    ).strip().casefold()

    rows = [
        entry
        for entry in entries
        if query
        in entry.get("service", "").casefold()
        or query
        in entry.get("username", "").casefold()
    ]
    list_entries(rows)


def get_entry_from_input(entries):
    value = input("شناسه حساب: ").strip()

    try:
        entry_id = int(value)
    except ValueError:
        print("شناسه نامعتبر است.")
        return None

    entry = find_entry_by_id(entries, entry_id)
    if entry is None:
        print("حساب پیدا نشد.")
    return entry


def reveal_password(entries):
    entry = get_entry_from_input(entries)
    if entry is None:
        return

    print(f"سرویس: {entry['service']}")
    print(f"نام کاربری: {entry['username']}")
    print(f"رمز: {entry['password']}")


def edit_entry(entries, fernet):
    entry = get_entry_from_input(entries)
    if entry is None:
        return

    service = input(
        f"سرویس [{entry['service']}]: "
    ).strip()
    username = input(
        f"نام کاربری [{entry['username']}]: "
    ).strip()

    if service:
        entry["service"] = service
    if username:
        entry["username"] = username

    if input(
        "رمز تغییر کند؟ (y/n): "
    ).strip().lower() == "y":
        password = getpass("رمز جدید: ")
        if password:
            entry["password"] = password

    save_vault(entries, fernet)
    print("حساب ویرایش شد.")


def delete_entry(entries, fernet):
    entry = get_entry_from_input(entries)
    if entry is None:
        return

    confirm = input(
        f"حذف {entry['service']}؟ (y/n): "
    ).strip().lower()

    if confirm != "y":
        print("حذف لغو شد.")
        return

    entries.remove(entry)
    save_vault(entries, fernet)
    print("حساب حذف شد.")


def show_menu():
    print("\n=== Password Manager ===")
    print("1) افزودن حساب")
    print("2) نمایش حساب‌ها")
    print("3) جست‌وجو")
    print("4) نمایش رمز")
    print("5) ویرایش")
    print("6) حذف")
    print("0) خروج")


def main():
    master_password = getpass(
        "Master Password: "
    )

    if not master_password:
        print("Master Password خالی است.")
        return

    fernet = create_fernet(master_password)

    try:
        entries = load_vault(fernet)
    except ValueError as error:
        print(error)
        return

    while True:
        show_menu()
        choice = input(
            "انتخاب شما: "
        ).strip()

        if choice == "1":
            add_entry(entries, fernet)
        elif choice == "2":
            list_entries(entries)
        elif choice == "3":
            search_entries(entries)
        elif choice == "4":
            reveal_password(entries)
        elif choice == "5":
            edit_entry(entries, fernet)
        elif choice == "6":
            delete_entry(entries, fernet)
        elif choice == "0":
            print("برنامه پایان یافت.")
            break
        else:
            print("گزینه نامعتبر است.")


if __name__ == "__main__":
    main()

نمونه اجرای برنامه

Master Password:

=== Password Manager ===
1) افزودن حساب
2) نمایش حساب‌ها
3) جست‌وجو
4) نمایش رمز
5) ویرایش
6) حذف
0) خروج

انتخاب شما: 1
نام سرویس: GitHub
نام کاربری یا ایمیل: developer@example.com
رمز خودکار؟ (y/n): y
حساب ذخیره شد.

اگر Master Password را فراموش کنیم چه می‌شود؟

در این طراحی Master Password جایی ذخیره نمی‌شود. این ویژگی از یک جهت امنیت را بهتر می‌کند، اما نتیجه مهمی دارد: اگر Master Password را فراموش کنید، برنامه راهی برای بازیابی کلید و Decrypt کردن Vault ندارد. بنابراین Master Password باید قوی، یکتا و قابل یادآوری باشد.

آیا ذخیره Salt کنار Vault خطرناک است؟

Salt یک Secret نیست. وظیفه آن ایجاد تنوع در مشتق‌سازی کلید است. چیزی که نباید ذخیره شود Master Password یا کلید نهایی رمزگذاری است.

چرا کل Vault را یک‌جا Encrypt کردیم؟

برای پروژه آموزشی، این مدل ساده‌تر است: فایل روی دیسک همیشه Ciphertext است و هنگام اجرای برنامه داده‌ها در حافظه Decrypt می‌شوند. برای Vaultهای بسیار بزرگ یا برنامه چندکاربره، معماری دیتابیس و رمزگذاری سطح رکورد مناسب‌تر خواهد بود.

اشتباه‌های رایج در پروژه Password Manager

ذخیره رمزها در JSON معمولی

بزرگ‌ترین خطا همین است. JSON قالب داده است، نه مکانیزم امنیتی.

ذخیره کلید رمزگذاری کنار Vault

اگر کلید و فایل رمزگذاری‌شده در یک محل و بدون حفاظت باشند، مهاجم با دسترسی به هر دو می‌تواند داده را باز کند. در این پروژه کلید از Master Password مشتق می‌شود و مستقیم ذخیره نمی‌شود.

استفاده از random برای تولید رمز

random برای شبیه‌سازی و بازی مناسب است، نه تولید Secret. برای این هدف از secrets استفاده می‌کنیم.

نمایش همه رمزها در فهرست

فهرست اصلی فقط سرویس و نام کاربری را نشان می‌دهد. نمایش رمز یک عملیات جداگانه است.

استفاده از Master Password ضعیف

رمزگذاری قوی نمی‌تواند یک Master Password بسیار ساده را به‌طور جادویی امن کند. امنیت Vault تا حد زیادی به کیفیت رمز اصلی وابسته است.

چطور پروژه را توسعه دهیم؟

  1. Clipboard: رمز را برای چند ثانیه در Clipboard قرار دهید و سپس پاک کنید.
  2. قفل خودکار: بعد از چند دقیقه عدم فعالیت، داده‌های Decrypt‌شده را از حافظه حذف و درخواست Master Password کنید.
  3. تغییر Master Password: Vault را با کلید قبلی باز و با کلید جدید دوباره رمزگذاری کنید.
  4. دسته‌بندی: حساب‌ها را به گروه‌هایی مثل شبکه اجتماعی، توسعه، مالی و ایمیل تقسیم کنید.
  5. URL: آدرس سایت را نیز برای هر رکورد ذخیره کنید.
  6. یادداشت امن: یک فیلد Note رمزگذاری‌شده اضافه کنید.
  7. بررسی رمز تکراری: هشدار دهید اگر یک رمز در چند حساب تکرار شده است.
  8. SQLite: در نسخه پیشرفته داده‌ها را در دیتابیس نگه دارید.
  9. GUI: با Tkinter یک رابط گرافیکی بسازید.
  10. Auto-lock: با Timer نشست را بعد از مدت مشخص قفل کنید.

این پروژه چه مفاهیمی را تمرین می‌دهد؟

  • JSON و File I/O
  • Pathlib
  • رمزگذاری متقارن
  • Key Derivation Function
  • Salt
  • Master Password
  • Fernet
  • Argon2id
  • secrets
  • getpass
  • Exception Handling
  • List و Dictionary
  • جست‌وجو و فیلتر
  • CRUD
  • اعتبارسنجی ورودی
  • طراحی منوی تعاملی

جمع‌بندی

در این آموزش یک برنامه مدیریت رمزهای عبور با پایتون ساختیم که اطلاعات حساب‌ها را ثبت، جست‌وجو، ویرایش و حذف می‌کند و می‌تواند رمز تصادفی تولید کند. تفاوت اصلی این پروژه با یک CRUD معمولی این است که Vault روی دیسک به‌صورت Plain Text ذخیره نمی‌شود؛ داده‌ها با Fernet رمزگذاری می‌شوند و کلید از Master Password و Salt مشتق می‌شود.

این پروژه برای درک مفاهیم امنیتی و طراحی نرم‌افزار آموزشی بسیار مناسب است، اما برای نگهداری واقعی رمزهای بسیار حساس بهتر است از Password Managerهای حرفه‌ای، بررسی‌شده و به‌روزرسانی‌شونده استفاده شود.

📌 همچنین بخوانید: تمرین پایتون با جواب، ساخت بازی با پایتون و Pygame و ساخت ربات با پایتون

می‌خواهی پیشرفتت ذخیره شود؟با ورود یا ثبت‌نام رایگان، درس‌های تمام‌شده را علامت بزن و درصد پیشرفتت را در حساب کاربری ببین.ورود / ثبت‌نام برای ذخیره پیشرفت
درس بعدی · پروژه‌های پایتون (18 از 19)مرتب‌سازی خودکار فایل‌ها با پایتون؛ پروژه کامل File Organizer با Pathlib ←
آموزش‌های تازه، کد آماده و رفع خطاهر هفته در کانال Dev Source؛ رایگان.

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *