ساخت برنامه مدیریت رمزهای عبور با پایتون؛ پروژه امنتر با 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.saltvault.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 saltSalt تصادفی باعث میشود دو کاربر با 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 تا حد زیادی به کیفیت رمز اصلی وابسته است.
چطور پروژه را توسعه دهیم؟
- Clipboard: رمز را برای چند ثانیه در Clipboard قرار دهید و سپس پاک کنید.
- قفل خودکار: بعد از چند دقیقه عدم فعالیت، دادههای Decryptشده را از حافظه حذف و درخواست Master Password کنید.
- تغییر Master Password: Vault را با کلید قبلی باز و با کلید جدید دوباره رمزگذاری کنید.
- دستهبندی: حسابها را به گروههایی مثل شبکه اجتماعی، توسعه، مالی و ایمیل تقسیم کنید.
- URL: آدرس سایت را نیز برای هر رکورد ذخیره کنید.
- یادداشت امن: یک فیلد Note رمزگذاریشده اضافه کنید.
- بررسی رمز تکراری: هشدار دهید اگر یک رمز در چند حساب تکرار شده است.
- SQLite: در نسخه پیشرفته دادهها را در دیتابیس نگه دارید.
- GUI: با Tkinter یک رابط گرافیکی بسازید.
- 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 و ساخت ربات با پایتون