パスワードマネージャー
パスワードを暗号化して保存する仕組みを学ぶ学習用パスワードマネージャー。cryptographyライブラリでAES暗号化を学びます。
1. アプリ概要
パスワードを暗号化して保存する仕組みを学ぶ学習用パスワードマネージャー。cryptographyライブラリでAES暗号化を学びます。
Fernet の暗号鍵は、マスターパスワードを PBKDF2HMAC(ハッシュ SHA-256・鍵長 32 バイト・ソルト os.urandom(16)=128 ビットのランダム値・反復回数 1,200,000 回)に通して導出しています。この 4 つのパラメータは cryptography 公式ドキュメントの PBKDF2HMAC の例と同じ値です。出典: cryptography 公式ドキュメント(Key derivation functions)。ソルトは初回セットアップ時に生成して passwords.key に kdf_salt として保存するので、同じマスターパスワードでもソルトが違えば別の鍵になります(当サイトで実行して確認しました)。反復回数を 1,200,000 回にすると導出に時間がかかり、手元の Windows 11・Python 3.12・cryptography 48.0.0 では 1 回あたり 0.2〜0.4 秒でした。ログインのたびにこの待ちが入るので、ステータス欄に「鍵を導出しています…」と表示しています。
マスターパスワードが正しいかどうかは、導出した鍵で passwords.enc を復号できるかで確かめています。鍵ファイル passwords.key に入っているのは version・kdf_salt・kdf_iterations の 3 つだけで、パスワードの照合用ハッシュは保存していません。
前の版は、ログイン照合用にソルト付きの SHA-256 を 1 回通しただけのハッシュを鍵ファイルに保存していました。鍵ファイルを持ち出した攻撃者は、反復回数の重い PBKDF2 を通らずに、この高速なハッシュ側からマスターパスワードを総当たりできました。現在の版ではこの照合用ハッシュを廃止したので、照合用ハッシュによる総当たりの近道は無くなりました(パスワードを 1 つ試すたびに PBKDF2 を通す形の総当たりは、今も可能です)。あわせて、cryptography が入っていないときに平文の JSON を passwords.enc という名前で保存していた「デモモード」を削除しました。現在の版は保存データを平文で保存せず、暗号化できないときは起動しません。
それでもこのコードが実運用の基準を満たすとは言えません。次の弱点が残っているからです。暗号化データ(passwords.enc)と鍵ファイル(passwords.key)を同じフォルダに置いています。入力後のマスターパスワードをメモリから確実に消す方法が無く、del pw で変数を消しても文字列がメモリから消えた保証にはなりません。クリップボードの自動クリアと、一定時間で自動ロックする仕組みもありません。第三者の監査も受けていません。
このコードをそのまま本番のパスワード管理に使わないでください。実際にパスワードを保管するときは、実績のある専用パスワードマネージャーをご利用ください。
前の版(鍵ファイルの version が 2・照合用ハッシュ付き)を使っていた方へ: 新しい app11.py でログインすると、保存データを引き継いだまま新しい形式へ自動で移ります。次の順に進めてください。
- 念のため、
passwords.keyとpasswords.encを別のフォルダにコピーしてバックアップします。前の版をデモモード(cryptography なし)で使っていた方は、ここでpip install cryptographyを実行してください(入っていないと新しい版は起動しません)。 - 新しい
app11.pyを同じフォルダに置き、いつものマスターパスワードでログインします。「旧形式の鍵ファイルを新形式に移しました(N 件)」と表示されれば、データはそのまま新しい形式に移っています。パスワードが違うときはエラーになり、ファイルは書き換えません。 - 一覧で中身を確認できたら、バックアップした
passwords.keyを削除してください。旧形式の照合用ハッシュが残っているからです。前の版をデモモードで使っていた方は、バックアップのpasswords.encも平文(暗号化されていない JSON)なので削除してください。
最初期の版(ソルト無しの SHA-256 で鍵を作っていた版・鍵ファイルに version の項目が無いもの)の passwords.key は、新しい版では開けません(移行機能はありません)。アプリは「旧形式のデータです」と案内し、ファイルを変更せずに止まります。手元に残っている旧バージョンの app11.py(このページの4章で使っているファイル名)でログインして必要な項目を控えてから、passwords.key と passwords.enc を別の場所へ移し、マスターパスワードを作り直してください。控えた項目は、作り直したあとに入れ直します。旧バージョンの app11.py が手元に無い場合、このページでは旧版を配布していないため、この版では保存データを取り出せません(マスターパスワードを忘れた場合と同じ扱いになります)。
使用ライブラリは tkinter(標準ライブラリ)・cryptography で、難易度は ★★★ です。
コピー&実行で動作確認 → コード解説で構造理解 → カスタマイズで応用、の 3 ステップで進めると、短時間で本質を押さえられます。
アプリを完成させた後は、自分の使い方に合わせて改造するのが学びを定着させる近道です。カスタマイズ章のアイデアを足がかりに、独自機能を一つ追加してみてください。
2. 機能一覧
- ループで生成するボタン群を
_toggle_pwd()に接続 - 「🔒 ロック」ボタン(
_lock())・ボタン(_authenticate())で操作 - キー
<Return>から_authenticate()を実行 - イベント
<<ListboxSelect>>から_on_select()を実行 tk.Listboxによる一覧表示(スクロールバー付き)messagebox.askyesno()・messagebox.showerror()・messagebox.showinfo()・messagebox.showwarning()によるダイアログ通知- ウィンドウはタイトル「パスワードマネージャー」・サイズ 780x540 で起動
- 保存データ(JSON)を Fernet で暗号化して
passwords.encに保存し、ログイン時に復号して読み込む(平文では保存しない) - マスターパスワードから
PBKDF2HMACで鍵を導出し、passwords.encを復号できるかでパスワードを確認 - cryptography が入っていないときは、エラーダイアログを出して起動しない
- 前の版(鍵ファイルの
versionが 2)の鍵ファイルを、ログイン時に新しい形式へ自動で移行 - 「✏️ 更新」「🗑️ 削除」は一覧で今選んでいる行が対象で、選んでいないときは警告を出して何もしない(追加・更新・削除・検索のたびに詳細欄を空にする)
3. 事前準備・環境
Python 3.11 以上(Ubuntu で 3.14・3.15 の起動を確認) / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)
Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。
- Python 3.11 以上(Ubuntu で 3.14・3.15 の起動を確認)
- cryptography(
pip install cryptography・必須) - OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)
cryptography が入っていない状態で起動すると、「cryptography が必要です。pip install cryptography でインストールしてから、もう一度起動してください。」というエラーダイアログを出して終了します。暗号化できない状態で、平文のまま保存することはありません。起動に使う Python が複数ある場合は、python -m pip install cryptography のように、起動に使う Python と同じものでインストールしてください。
4. 完全なソースコード
右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。
import tkinter as tk
from tkinter import ttk, messagebox
import json
import os
import hashlib
import hmac
import base64
import secrets
import string
try:
from cryptography.fernet import Fernet, InvalidToken
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
CRYPTO_AVAILABLE = True
except ImportError:
# 暗号化できないときは起動しない(平文で保存してしまう「デモモード」は持たない)
CRYPTO_AVAILABLE = False
class App11:
"""パスワードマネージャー"""
DATA_FILE = os.path.join(os.path.dirname(__file__), "passwords.enc")
KEY_FILE = os.path.join(os.path.dirname(__file__), "passwords.key")
KEY_VERSION = 3 # 鍵ファイルの形式(2 = 照合用ハッシュ付きの旧形式・自動で移行)
KDF_ITERATIONS = 1200000 # cryptography 公式ドキュメントの例と同じ反復回数
KDF_SALT_BYTES = 16 # 128 ビット(公式ドキュメントが示すソルトの下限)
def __init__(self, root):
self.root = root
self.root.title("パスワードマネージャー")
self.root.geometry("780x540")
self.root.configure(bg="#f8f9fc")
self.fernet = None
self.entries = []
if not CRYPTO_AVAILABLE:
# 暗号化できない状態では何も保存せずに終了する
messagebox.showerror(
"ライブラリ未インストール",
"cryptography が必要です。\n"
"pip install cryptography でインストールしてから、"
"もう一度起動してください。")
self.root.destroy()
return
self._build_ui()
self._show_login()
def _build_ui(self):
title_frame = tk.Frame(self.root, bg="#c62828", pady=10)
title_frame.pack(fill=tk.X)
tk.Label(title_frame, text="🔒 パスワードマネージャー",
font=("Noto Sans JP", 15, "bold"),
bg="#c62828", fg="white").pack(side=tk.LEFT, padx=12)
self.lock_btn = ttk.Button(title_frame, text="🔒 ロック",
command=self._lock)
self.lock_btn.pack(side=tk.RIGHT, padx=12)
# メインコンテンツ
self.main_frame = tk.Frame(self.root, bg="#f8f9fc")
self.main_frame.pack(fill=tk.BOTH, expand=True)
# ログイン/セットアップフレーム
self.login_frame = tk.Frame(self.main_frame, bg="#f8f9fc")
# パスワード一覧フレーム
self.list_frame = tk.Frame(self.main_frame, bg="#f8f9fc")
self.status_var = tk.StringVar(value="")
tk.Label(self.root, textvariable=self.status_var,
bg="#dde", font=("Arial", 9), anchor="w", padx=8
).pack(fill=tk.X, side=tk.BOTTOM)
def _show_login(self):
self.list_frame.pack_forget()
self.login_frame.pack(fill=tk.BOTH, expand=True)
for w in self.login_frame.winfo_children():
w.destroy()
c = tk.Frame(self.login_frame, bg="#f8f9fc")
c.place(relx=0.5, rely=0.4, anchor="center")
has_data = os.path.exists(self.KEY_FILE)
title = "マスターパスワードを入力" if has_data else "マスターパスワードを設定"
tk.Label(c, text=title, font=("Noto Sans JP", 14, "bold"),
bg="#f8f9fc").pack(pady=8)
frame = tk.Frame(c, bg="#f8f9fc")
frame.pack()
tk.Label(frame, text="マスターパスワード:",
bg="#f8f9fc").grid(row=0, column=0, sticky="w", pady=4)
self.master_entry = ttk.Entry(frame, show="●", width=24,
font=("Arial", 12))
self.master_entry.grid(row=0, column=1, padx=8)
if not has_data:
tk.Label(frame, text="確認用:",
bg="#f8f9fc").grid(row=1, column=0, sticky="w", pady=4)
self.master_confirm = ttk.Entry(frame, show="●", width=24,
font=("Arial", 12))
self.master_confirm.grid(row=1, column=1, padx=8)
else:
self.master_confirm = None
btn_text = "ログイン" if has_data else "作成"
ttk.Button(c, text=btn_text,
command=self._authenticate).pack(pady=12)
self.master_entry.bind("<Return>", lambda e: self._authenticate())
self.master_entry.focus_set()
def _derive_key(self, password, kdf_salt, iterations):
"""マスターパスワードとソルトから Fernet 用の鍵(32バイト)を導出する。
パラメータは cryptography 公式ドキュメント(Key derivation functions)
の PBKDF2HMAC の例に合わせている。
https://cryptography.io/en/latest/hazmat/primitives/key-derivation-functions/
"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=kdf_salt,
iterations=iterations,
)
return base64.urlsafe_b64encode(kdf.derive(password.encode()))
def _write_key_file(self, kdf_salt, iterations):
# 鍵ファイルにはソルトと反復回数だけを書く(パスワードの照合用ハッシュは持たない)
data = {"version": self.KEY_VERSION, "kdf_salt": kdf_salt.hex(),
"kdf_iterations": iterations}
self._write_file(self.KEY_FILE, json.dumps(data).encode())
def _authenticate(self):
pw = self.master_entry.get()
if not pw:
messagebox.showwarning("警告", "パスワードを入力してください")
return
has_data = os.path.exists(self.KEY_FILE)
if has_data:
# 認証: passwords.enc を復号できたら、マスターパスワードが正しい
try:
with open(self.KEY_FILE, "r", encoding="utf-8") as f:
stored = json.load(f)
version = stored.get("version")
if version in (2, self.KEY_VERSION):
kdf_salt = bytes.fromhex(stored["kdf_salt"])
iterations = int(stored.get("kdf_iterations",
self.KDF_ITERATIONS))
except (OSError, ValueError, KeyError, TypeError, AttributeError):
messagebox.showerror(
"エラー", "鍵ファイル(passwords.key)を読み込めませんでした")
return
if version not in (2, self.KEY_VERSION):
messagebox.showerror(
"旧形式のデータです",
"この鍵ファイルは古い形式(ソルト無しの鍵導出)で"
"作られているため、このバージョンでは開けません。\n"
"ファイルは変更していません。手元に残っている"
"旧バージョンの app11.py でログインして中身を控えてから、"
"passwords.key と passwords.enc を別の場所へ移して"
"作り直してください。")
return
self.status_var.set("鍵を導出しています…")
self.root.update_idletasks()
fernet = Fernet(self._derive_key(pw, kdf_salt, iterations))
self.status_var.set("")
if version == 2:
ok = self._migrate_v2(pw, stored, fernet, kdf_salt, iterations)
else:
ok = self._load_passwords(fernet)
if not ok:
return
else:
# 新規作成
if self.master_confirm and pw != self.master_confirm.get():
messagebox.showerror("エラー", "パスワードが一致しません")
return
if os.path.exists(self.DATA_FILE):
# 鍵ファイルだけ消えた状態で作り直すと、既存のデータを上書きしてしまう
messagebox.showerror(
"エラー",
"passwords.enc はあるのに passwords.key がありません。\n"
"上書きを防ぐため、新規作成を中止しました。")
return
kdf_salt = os.urandom(self.KDF_SALT_BYTES)
self.status_var.set("鍵を導出しています…")
self.root.update_idletasks()
self.fernet = Fernet(
self._derive_key(pw, kdf_salt, self.KDF_ITERATIONS))
self.entries = []
# 空のリストを暗号化して保存する。次回はこれを復号できるかでパスワードを確かめる
self._save_passwords()
self._write_key_file(kdf_salt, self.KDF_ITERATIONS)
del pw
self.master_entry.delete(0, tk.END)
if self.master_confirm:
self.master_confirm.delete(0, tk.END)
self._show_manager()
def _load_passwords(self, fernet):
"""passwords.enc を復号して読み込む。成功したら True を返す。"""
try:
with open(self.DATA_FILE, "rb") as f:
raw = f.read()
entries = json.loads(fernet.decrypt(raw))
except FileNotFoundError:
messagebox.showerror(
"データがありません",
"passwords.enc が見つかりません。\n"
"passwords.key と同じフォルダに置いてください。")
return False
except InvalidToken:
messagebox.showerror(
"エラー",
"パスワードが正しくありません。\n"
"(passwords.key と passwords.enc の組み合わせが"
"違う場合も、このエラーになります)")
return False
except (OSError, ValueError):
messagebox.showerror(
"読み込みエラー", "保存データを読み込めませんでした")
return False
self.fernet = fernet
self.entries = entries
return True
def _migrate_v2(self, pw, stored, fernet, kdf_salt, iterations):
"""旧形式(version 2)の鍵ファイルを新形式に移す。保存データは引き継ぐ。
version 2 は照合用の SHA-256 ハッシュ(hash・salt)を持っていたので、
移行のときだけそれでパスワードを確かめ、移行後の鍵ファイルからは消す。
"""
try:
hashed = hashlib.sha256((pw + stored["salt"]).encode()).hexdigest()
if not hmac.compare_digest(hashed, stored["hash"]):
messagebox.showerror("エラー", "パスワードが正しくありません")
return False
except (KeyError, TypeError):
messagebox.showerror(
"エラー", "鍵ファイル(passwords.key)を読み込めませんでした")
return False
entries = []
try:
if os.path.exists(self.DATA_FILE):
with open(self.DATA_FILE, "rb") as f:
raw = f.read()
try:
entries = json.loads(fernet.decrypt(raw))
except InvalidToken:
# 旧版のデモモード(暗号化なし)で保存された平文のデータ
entries = json.loads(raw)
except (OSError, ValueError):
messagebox.showerror(
"読み込みエラー",
"保存データを読み込めませんでした(ファイルは変更していません)")
return False
self.fernet = fernet
self.entries = entries
# 先にデータを暗号化して保存し、そのあと鍵ファイルを新形式に書き換える
self._save_passwords()
self._write_key_file(kdf_salt, iterations)
messagebox.showinfo(
"移行しました",
f"旧形式の鍵ファイルを新形式に移しました({len(entries)} 件)。")
return True
def _show_manager(self):
self.login_frame.pack_forget()
self.list_frame.pack(fill=tk.BOTH, expand=True)
for w in self.list_frame.winfo_children():
w.destroy()
paned = ttk.PanedWindow(self.list_frame, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True, padx=4, pady=4)
# 左: 一覧
left = tk.Frame(paned, bg="#f8f9fc")
tk.Label(left, text="🔍 検索:").pack(anchor="w", padx=8)
self.search_var = tk.StringVar()
search_entry = ttk.Entry(left, textvariable=self.search_var, width=26)
search_entry.pack(fill=tk.X, padx=8, pady=2)
self.search_var.trace_add("write", lambda *a: self._refresh_list())
# exportselection=False: 詳細欄で文字を選んでも一覧の選択が外れないようにする
self.lb = tk.Listbox(left, font=("Arial", 11),
selectbackground="#c62828",
selectforeground="white",
exportselection=False)
sb = ttk.Scrollbar(left, command=self.lb.yview)
self.lb.configure(yscrollcommand=sb.set)
sb.pack(side=tk.RIGHT, fill=tk.Y)
self.lb.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
self.lb.bind("<<ListboxSelect>>", self._on_select)
paned.add(left, weight=1)
# 右: 詳細・編集
right = ttk.LabelFrame(paned, text="詳細・編集", padding=12)
for lbl, attr in [("サービス名", "svc"), ("ユーザー名", "usr"),
("パスワード", "pwd"), ("URL", "url"),
("メモ", "memo")]:
tk.Label(right, text=f"{lbl}:").pack(anchor="w")
if lbl == "メモ":
var = tk.Text(right, height=3, width=28, font=("Arial", 11))
var.pack(fill=tk.X, pady=2)
setattr(self, f"{attr}_text", var)
else:
var = tk.StringVar()
entry = ttk.Entry(right, textvariable=var, width=28,
show="●" if lbl == "パスワード" else "")
entry.pack(fill=tk.X, pady=2)
setattr(self, f"{attr}_var", var)
if lbl == "パスワード":
self.pwd_entry = entry
btn_f = tk.Frame(right, bg=ttk.Style().lookup("TLabelframe", "background"))
btn_f.pack(fill=tk.X)
ttk.Button(btn_f, text="👁 表示",
command=self._toggle_pwd).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_f, text="🎲 生成",
command=self._gen_password).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_f, text="📋 コピー",
command=self._copy_pwd).pack(side=tk.LEFT, padx=2)
btn_frame = tk.Frame(right, bg=ttk.Style().lookup("TLabelframe", "background"))
btn_frame.pack(fill=tk.X, pady=8)
for text, cmd in [("➕ 追加", self._add_entry),
("✏️ 更新", self._update_entry),
("🗑️ 削除", self._delete_entry)]:
ttk.Button(btn_frame, text=text, command=cmd).pack(
side=tk.LEFT, padx=4)
paned.add(right, weight=1)
self._refresh_list()
self.status_var.set(f"{len(self.entries)} 件")
def _write_file(self, path, data):
"""一時ファイルに書いてから置き換える(書き込み途中で落ちても元のファイルが残る)"""
tmp = path + ".tmp"
with open(tmp, "wb") as f:
f.write(data)
os.replace(tmp, path)
def _save_passwords(self):
if self.fernet is None:
# 鍵が無いまま平文で書き出すことはしない
raise RuntimeError("ロック中のため保存できません")
raw = json.dumps(self.entries, ensure_ascii=False).encode()
self._write_file(self.DATA_FILE, self.fernet.encrypt(raw))
def _refresh_list(self):
q = self.search_var.get().lower()
self.lb.delete(0, tk.END)
self._filtered = [e for e in self.entries
if q in e.get("svc", "").lower()
or q in e.get("usr", "").lower()]
for e in self._filtered:
self.lb.insert(tk.END, f" {e.get('svc', '')} ({e.get('usr', '')})")
# 一覧を作り直すと選択が外れるので、フォームも空にして古い内容を残さない
self._clear_form()
def _clear_form(self):
for var in (self.svc_var, self.usr_var, self.pwd_var, self.url_var):
var.set("")
self.memo_text.delete("1.0", tk.END)
def _current_index(self):
"""一覧で今選ばれている行が self.entries の何番目か(未選択なら None)"""
sel = self.lb.curselection()
if not sel or sel[0] >= len(self._filtered):
return None
target = self._filtered[sel[0]]
for i, e in enumerate(self.entries):
if e is target: # 同じ内容の項目があっても、選んだ行そのものを指す
return i
return None
def _on_select(self, event):
idx = self._current_index()
if idx is None:
return
e = self.entries[idx]
self.svc_var.set(e.get("svc", ""))
self.usr_var.set(e.get("usr", ""))
self.pwd_var.set(e.get("pwd", ""))
self.url_var.set(e.get("url", ""))
self.memo_text.delete("1.0", tk.END)
self.memo_text.insert("1.0", e.get("memo", ""))
def _add_entry(self):
svc = self.svc_var.get().strip()
if not svc:
messagebox.showwarning("警告", "サービス名を入力してください")
return
entry = {
"svc": svc, "usr": self.usr_var.get(),
"pwd": self.pwd_var.get(), "url": self.url_var.get(),
"memo": self.memo_text.get("1.0", tk.END).strip()
}
self.entries.append(entry)
self._save_passwords()
self._refresh_list()
self.status_var.set(f"{len(self.entries)} 件")
def _update_entry(self):
# 覚えておいた番号ではなく、今一覧で選ばれている行を対象にする
idx = self._current_index()
if idx is None:
messagebox.showwarning("警告", "更新するエントリを一覧から選んでください")
return
self.entries[idx] = {
"svc": self.svc_var.get(), "usr": self.usr_var.get(),
"pwd": self.pwd_var.get(), "url": self.url_var.get(),
"memo": self.memo_text.get("1.0", tk.END).strip()
}
self._save_passwords()
self._refresh_list()
def _delete_entry(self):
idx = self._current_index()
if idx is None:
messagebox.showwarning("警告", "削除するエントリを一覧から選んでください")
return
if messagebox.askyesno("確認", "このエントリを削除しますか?"):
self.entries.pop(idx)
self._save_passwords()
self._refresh_list()
self.status_var.set(f"{len(self.entries)} 件")
def _toggle_pwd(self):
show = self.pwd_entry.cget("show")
self.pwd_entry.config(show="" if show else "●")
def _gen_password(self):
chars = string.ascii_letters + string.digits + "!@#$%&"
pwd = "".join(secrets.choice(chars) for _ in range(16))
self.pwd_var.set(pwd)
def _copy_pwd(self):
self.root.clipboard_clear()
self.root.clipboard_append(self.pwd_var.get())
self.status_var.set("パスワードをクリップボードにコピーしました")
def _lock(self):
self.fernet = None
self.entries = []
if hasattr(self, "lb"):
# 一覧とフォームを空にしてから閉じる(ロック後に中身や選択が残らないように)
self._refresh_list()
self._show_login()
if __name__ == "__main__":
root = tk.Tk()
app = App11(root)
root.mainloop()
5. コード解説
パスワードマネージャーのコードを、実際に書かれている実装に沿って解説します。
クラス設計とコンストラクタ
App11 クラスにアプリの全機能をまとめています(メソッド22個・全454行)。__init__ ではタイトル「パスワードマネージャー」とウィンドウサイズ 780x540 を設定します。あわせて状態を保持する変数(self.fernet)を初期化し、そのあと _build_ui() → _show_login() の順に呼び出して初期状態を作ります。cryptography を読み込めなかったとき(CRYPTO_AVAILABLE が False)は、画面を作る前にエラーダイアログを出し、root.destroy() で終了します。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
ウィジェット構成
画面は tk.Frame×8(ほかにループでも生成)・tk.Label×6(ほかにループでも生成)・ttk.Button×2(ほかにループでも生成)・ttk.Entry×3(ほかにループでも生成)・ttk.PanedWindow・tk.Listbox・ttk.Scrollbar・ttk.LabelFrame・tk.Text(ループで生成)・ttk.Style(ループで生成) で構成しています。見出し付きの枠(LabelFrame)は「詳細・編集」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
イベント処理とボタンの接続
「🔒 ロック」ボタン(_lock())・ボタン(_authenticate())を command= で接続しています。ボタンはループ内で生成し、_toggle_pwd() に接続しています。また bind("<Return>", ...) から _authenticate()、bind("<<ListboxSelect>>", ...) から _on_select() を呼べるようにイベントも登録しています。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
暗号化して保存する・パスワードを確かめる
初めて「作成」を押すと、os.urandom(16) でソルトを作り、_derive_key() でマスターパスワードから鍵を導出します。続けて空のリストを暗号化して passwords.enc を作り、最後に _write_key_file() で passwords.key に version(3)・kdf_salt・kdf_iterations だけを書きます。鍵ファイルには、マスターパスワードそのものも照合用ハッシュも入りません。
2回目以降のログインでは、鍵ファイルのソルトと反復回数で鍵を導出し、_load_passwords() で passwords.enc を fernet.decrypt() します。Fernet は暗号文に改ざん検知用の HMAC を付けているので、違う鍵で復号しようとすると InvalidToken になります。復号できたら正しいパスワード、InvalidToken なら「パスワードが正しくありません」と表示します。初回に空のリストを暗号化して保存しておくのは、この確認に使うためです。
保存は _save_passwords() が行います。一覧を json.dumps() で文字列にしてから self.fernet.encrypt() で暗号化し、_write_file() で書き出します。self.fernet が None(ロック中)のときは RuntimeError を出して止まり、平文では書き出しません。_write_file() は一時ファイル(.tmp)に書いてから os.replace() で置き換えるので、書き込みの途中で止まっても元のファイルが残ります。
passwords.enc があるのに passwords.key が無いときは、そのまま作り直すと既存のデータを上書きしてしまうため、新規作成を中止します。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
旧形式の鍵ファイルを移行する(_migrate_v2())
_authenticate() は鍵ファイルの version を見て処理を分けます。3 なら通常のログイン、2 なら _migrate_v2() で移行し、それ以外(version の項目が無い、ソルト無しの最初期版など)はファイルを変更せずに止まります。
_migrate_v2() は、移行のときだけ旧形式の照合用ハッシュ(hash・salt)で、パスワードを hmac.compare_digest() で確かめます。合っていれば passwords.enc を復号し、InvalidToken になったら旧版のデモモードで保存された平文の JSON とみなしてそのまま読みます。そのあと先にデータを暗号化して保存し、次に鍵ファイルを新しい形式に書き換えます(hash・salt はここで消えます)。この順序なので、途中で止まっても鍵ファイルは旧形式のまま残り、次のログインでもう一度移行できます。パスワードが違うときは何も書き換えません。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
クラス定数によるデータ定義
クラスの先頭でデータを定数として定義しています:DATA_FILE = os.path.join(os.path.dirname(__file__), 'passwords.enc')、KEY_FILE = os.path.join(os.path.dirname(__file__), 'passwords.key')。ロジックの中に直接書かず定数にまとめておくと、内容を変えたいときに1か所を書き換えるだけで済みます。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
例外処理
try-except で AttributeError・FileNotFoundError・ImportError・InvalidToken・KeyError・OSError・TypeError・ValueError を捕捉しています。あわせて messagebox.askyesno()・messagebox.showerror()・messagebox.showinfo()・messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。
※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。
6. ステップバイステップガイド
このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。
-
1ファイルを作成する
新しいファイルを作成して app11.py と保存します。外部ライブラリ
cryptographyを使います。先にpip install cryptographyを実行してください。 -
2クラスの骨格を作る
App11クラスを定義し、__init__と、末尾のroot = tk.Tk()~mainloop()の最小構成を書いて、まず空のウィンドウが出ることを確認します。 -
3ウィンドウを設定する
title("パスワードマネージャー")・geometry("780x540")・configure(bg="#f8f9fc")をコンストラクタで設定します。 -
4画面部品を並べる
_build_ui()の中で、tk.Frame×8(ほかにループでも生成)・tk.Label×6(ほかにループでも生成)・ttk.Button×2(ほかにループでも生成)・ttk.Entry×3(ほかにループでも生成)・ttk.PanedWindow・tk.Listbox・ttk.Scrollbar・ttk.LabelFrame・tk.Text(ループで生成)・ttk.Style(ループで生成) を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。 -
5イベントを接続する
「2. 機能一覧」で挙げた各ボタンを
command=で対応するメソッド(_lock()・_authenticate())につなぎます。ループ生成のボタンはcommand=self._toggle_pwdの形で接続します。bind("<Return>", ...)・bind("<<ListboxSelect>>", ...)の登録も忘れずに。 -
6中心になるメソッドを実装する
アプリの本体である
_authenticate()(67行)・_on_select()(11行)・_add_entry()(14行)など を実装します。 -
7動作確認する
python app11.pyで起動し、各ボタンが反応すること・キー操作(<Return>)が効くことを確認します。
7. カスタマイズアイデア
基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。
💡 ダークモードを追加する
bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。
💡 クラス定数を書き換えて動きを変える
データはクラス定数(DATA_FILE・KEY_FILE)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。
💡 入力履歴機能
以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。
8. よくある問題と解決法
❌ 文字のフォントが崩れる・見た目が違う
原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。
解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。
❌ <Return> キーを押しても反応しない
原因:bind("<Return>", ...) はウィジェット単位で登録されるため、ウィンドウ(またはバインド先)にキーボードフォーカスが無いとイベントが届きません。
解決法:一度ウィンドウ内をクリックしてフォーカスを与えてから、キーを押してください。
❌ ウィンドウの大きさが画面に合わない
原因:geometry("780x540") の固定サイズで起動するためです。
解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。
❌ 起動するとすぐ「ライブラリ未インストール」のダイアログが出て終了する
原因:起動に使った Python に cryptography が入っていません。このアプリは暗号化できない状態では起動しません。
解決法:pip install cryptography を実行してから、もう一度起動してください。Python が複数入っている場合は、python -m pip install cryptography のように、起動に使う Python と同じものでインストールします。
❌ 「パスワードが正しくありません」と出てログインできない
原因:マスターパスワードの入力違いのほか、passwords.key と passwords.enc の組み合わせが違う場合(片方だけ別のフォルダからコピーした等)も、このエラーになります。どちらも、導出した鍵で passwords.enc を復号できなかったという意味です。
解決法:入力(Caps Lock・全角/半角)を確かめ、同じ時に作った passwords.key と passwords.enc を同じフォルダに置いてください。マスターパスワードを忘れた場合、このアプリには再設定の機能が無いため、保存データは取り出せません。
❌ 「passwords.enc はあるのに passwords.key がありません」と出る
原因:鍵ファイルだけが消えたか、別の場所に移っています。このまま新規作成すると既存のデータを上書きしてしまうため、アプリが作成を中止しています。
解決法:passwords.key を同じフォルダに戻してください。鍵ファイルが無いと passwords.enc は復号できません。新しく作り直す場合は、passwords.enc を別の場所へ移してから起動します。
❌ 「旧形式のデータです」と出て開けない
原因:最初期の版(ソルト無しの SHA-256 で鍵を作っていた版)の鍵ファイルです。現在の版では開けず、移行機能もありません(ファイルは変更していません)。
解決法:1章の注意にある手順で、旧バージョンの app11.py で中身を控えてから、passwords.key と passwords.enc を別の場所へ移して作り直してください。旧バージョンの app11.py が手元に無い場合、この版では保存データを取り出せません。前の版(version が 2)の鍵ファイルなら、このエラーは出ずにログイン時に自動で移行します。
9. 練習問題
アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。このアプリは仕組みを学ぶための学習用です。実際のパスワードの保管には使わないでください(1章の注意も参照)。練習では test1 のような架空の名前と、その場で「🎲 生成」した値だけを入れてください。最初にマスターパスワードを設定して「作成」を押した時点で、同じフォルダに passwords.enc と passwords.key が作られます(ファイル名は24-25行・作成は192-193行)。下の2問は画面の操作まわりを直すもので、鍵の作り方(_derive_key()・111-124行)とマスターパスワードの扱いは変えないでください。課題1・課題2に追加のライブラリは要りません(アプリ本体は pip install cryptography をしていないと起動しません)。
-
課題1:同じサービス名・ユーザー名の組を追加すると、重複したまま2件になる
「➕ 追加」は、入力した内容を確かめずに一覧の末尾へ足します。すでに同じサービス名・ユーザー名の組があっても止まらないため、同じ項目が2行並びます。どちらが新しいパスワードなのか、一覧からは見分けられません。
期待結果:サービス名に
test1、ユーザー名にuser_aと入れて「➕ 追加」を押す(ステータス欄が1 件)。追加すると詳細欄は空になるので、もう一度同じtest1・user_aを入れて「➕ 追加」を押すと、一覧にtest1 (user_a)が2行並んで2 件になる。直したあとは、2回目の「➕ 追加」で警告が出て、件数は1 件のまま変わらない。合格条件
- まだ無い組を「➕ 追加」する動きは、今までどおり
- 同じサービス名・ユーザー名の組を追加しようとすると、警告が出て件数が増えない
- サービス名が同じでもユーザー名が違えば(
test1・user_bなど)、追加できる
ヒント①(どこを触るか): 触るのは1か所です。
_add_entry()(388-401行)のself.entries.append(entry)(398行)の直前。
ヒント②(使うもの):any(e.get("svc") == entry["svc"] and e.get("usr") == entry["usr"] for e in self.entries)で同じ組があるかを調べ、あればmessagebox.showwarning()を出してreturnします。returnすると一覧の作り直しも走らないので、入力した内容は詳細欄に残ります。
つまずきやすい点: サービス名はstrip()で前後の空白を落としていますが(389行)、ユーザー名はそのまま入れているため(394行)、user_aと末尾に空白の付いたuser_aは別の組として扱われます。大文字と小文字も区別されます(Test1とtest1は別)。すでに重複して2件になっているデータは、このチェックを足しても消えません。片方を選んで「🗑️ 削除」で消してください。_current_index()(365-374行)は選んだ行そのものをisで探すので、同じ内容の2件でも選んだほうが消えます。「✏️ 更新」でサービス名・ユーザー名を書き換えて、既にある組と同じにすることもできます(_update_entry()・403-415行)。そちらも防ぎたいときは、同じチェックを更新にも足し、自分自身(self.entries[idx])は比べる対象から外してください。 -
課題2:コピーしたパスワードがクリップボードに残り続ける
「📋 コピー」はパスワードをクリップボードへ入れます。消す処理はどこにもないため、別の物をコピーするまで残ったままです。ほかのアプリへうっかり貼り付けてしまう事故のもとになります。
期待結果:エントリを選んで「📋 コピー」を押し、メモ帳などに貼り付けられることを確かめる。ステータス欄は
パスワードをクリップボードにコピーしましたのまま変わらない。しばらく待ってからもう一度貼り付けても、同じ値が貼れる。直したあとは、決めた時間(30秒など)が過ぎるとクリップボードが空になり、貼り付けても何も入らない。合格条件
- 「📋 コピー」の直後は、今までどおり貼り付けられる
- 決めた時間が過ぎると、クリップボードからパスワードが消える
- そのあいだに別のアプリでコピーしたときは、そちらを消さない
ヒント①(どこを触るか): 触るのは2か所です。
_copy_pwd()(437-440行)の最後と、消すためのメソッドを1つ追加。
ヒント②(使うもの):self.root.after(30000, …)で30秒後の予約を入れておきます。消す側では、self.root.clipboard_get()が自分の入れた値と同じときだけself.root.clipboard_clear()を呼ぶ形が安全です。いつ消えるかをself.status_varに出しておくと親切です。
つまずきやすい点:clipboard_get()は、クリップボードが空のときや画像が入っているときにtk.TclErrorを出すので、try:とexcept tk.TclError:で囲んでください。中身を確かめずにclipboard_clear()を呼ぶと、そのあいだに別のアプリでコピーした内容まで消えます。_copy_pwd()は詳細欄が空でも押せて、空の文字列をクリップボードに入れます(439行)。予約が何本も重ならないよう、after_cancel()で前の予約を取り消す形にしておくと確実です。 -
課題3:新しいボタンを追加する
既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と
command=の接続がセットで身に付きます。
写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。
このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。