中級者向け No.11

パスワードマネージャー

パスワードを暗号化して保存する仕組みを学ぶ学習用パスワードマネージャー。cryptographyライブラリでAES暗号化を学びます。

🎯 難易度: ★★★ 📦 ライブラリ: tkinter(標準ライブラリ), cryptography ⏱️ 制作時間: 30〜90分

1. アプリ概要

パスワードを暗号化して保存する仕組みを学ぶ学習用パスワードマネージャー。cryptographyライブラリでAES暗号化を学びます。

⚠️
このコードは学習用です(鍵導出は標準的な手法・実運用の基準を満たすとは言えません)

Fernet の暗号鍵は、マスターパスワードを PBKDF2HMAC(ハッシュ SHA-256・鍵長 32 バイト・ソルト os.urandom(16)=128 ビットのランダム値・反復回数 1,200,000 回)に通して導出しています。この 4 つのパラメータは cryptography 公式ドキュメントの PBKDF2HMAC の例と同じ値です。出典: cryptography 公式ドキュメント(Key derivation functions)。ソルトは初回セットアップ時に生成して passwords.keykdf_salt として保存するので、同じマスターパスワードでもソルトが違えば別の鍵になります(当サイトで実行して確認しました)。反復回数を 1,200,000 回にすると導出に時間がかかり、手元の Windows 11・Python 3.12・cryptography 48.0.0 では 1 回あたり 0.2〜0.4 秒でした。ログインのたびにこの待ちが入るので、ステータス欄に「鍵を導出しています…」と表示しています。

それでもこのコードが実運用の基準を満たすとは言えません。弱点が残っているからです。ログイン照合用のハッシュはソルト付きの SHA-256 を 1 回通しただけで、鍵ファイルを持ち出した攻撃者は、反復回数の重い PBKDF2 を通らずにこの高速なハッシュ側からマスターパスワードを総当たりできます。ほかにも、暗号化データと鍵ファイルを同じフォルダに置いている、入力後のマスターパスワードをメモリから確実に消す処理・クリップボードの自動クリア・一定時間で自動ロックする仕組みが無い、第三者の監査を受けていない、といった点が学習用のままです。

このコードをそのまま本番のパスワード管理に使わないでください。実際にパスワードを保管するときは、実績のある専用パスワードマネージャーをご利用ください。

旧バージョン(ソルト無しの SHA-256 で鍵を作っていた版)の passwords.key が残っている場合、その保存データは新しい鍵導出では復号できません。アプリは鍵ファイルの version を見て「旧形式のデータです」と案内して停止します。passwords.keypasswords.enc を削除してから、マスターパスワードを作り直してください(旧データを読み替える移行機能はありません)。削除すると保存済みのパスワードは復元できません。必要な項目は、手元に残っている旧バージョンの app11.py(このページで配布しているファイル名)で先にログインして控えてから削除してください。

このアプリは中級カテゴリに分類される実践的なGUIアプリです。使用ライブラリは tkinter(標準ライブラリ)・cryptography で、難易度は ★★★ です。

このアプリは「ユーティリティ」カテゴリです。小回りの効くユーティリティは Python の真骨頂で、汎用的なユーザー操作を組み合わせる発想が他のアプリにもそのまま流用できます。tkinter(標準ライブラリ)・cryptography を活かして実装するこの構造は、他のアプリにも応用が効きます。

コピー&実行で動作確認 → コード解説で構造理解 → カスタマイズで応用、の 3 ステップで進めると、短時間で本質を押さえられます。

アプリを完成させた後は、自分の使い方に合わせて改造するのが学びを定着させる近道です。カスタマイズ章のアイデアを足がかりに、独自機能を一つ追加してみてください。

パスワードマネージャー 実行画面(Windows)
実行画面(Windows)
パスワードマネージャー 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • ループで生成するボタン群を _toggle_pwd() に接続
  • 「🔒 ロック」ボタン(_lock())・ボタン(_authenticate())で操作
  • キー <Return> から _authenticate() を実行
  • イベント <<ListboxSelect>> から _on_select() を実行
  • tk.Listbox による一覧表示(スクロールバー付き)
  • JSON ファイルの保存・読み込み(起動時に自動実行)
  • messagebox.askyesno()messagebox.showerror()messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「パスワードマネージャー」・サイズ 780x540 で起動

3. 事前準備・環境

ℹ️
動作確認環境

Python 3.10 以上 / Windows 11(実機)で起動・基本操作を確認 / Linux Mint 22.3(仮想マシン)で起動・画面表示を確認(macOS は未検証)

Windows 11(実機)で起動と基本操作を確認しています(全機能の網羅テストではありません)。Linux Mint 22.3(仮想マシン)では起動と画面表示を確認しました。macOS は必要なライブラリ(pip install)を入れれば動作する想定ですが、未検証です。

  • Python 3.10 以上
  • OS: Windows 11(実機で起動・基本操作を確認)・Linux Mint 22.3(仮想マシンで起動・画面表示を確認)

4. 完全なソースコード

💡
コードのコピー方法

右上の「コピー」ボタンをクリックするとコードをクリップボードにコピーできます。

📦 必要なライブラリをインストール(初回のみ)
pip install cryptography
app11.py
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 InvalidToken(Exception):
        """cryptography 未インストール時の代替(復号を行わないので発生しない)"""


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 = 2          # 鍵ファイルの形式(1 = ソルト無しの旧形式・復号不可)
    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 = []
        self.master_hash = None
        self._build_ui()
        self._check_crypto()

    def _check_crypto(self):
        if not CRYPTO_AVAILABLE:
            messagebox.showwarning(
                "ライブラリ未インストール",
                "cryptography が必要です。\n"
                "pip install cryptography でインストールしてください。\n\n"
                "デモモード(暗号化なし)で動作します。")
        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):
        """マスターパスワードとソルトから 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=self.KDF_ITERATIONS,
        )
        return base64.urlsafe_b64encode(kdf.derive(password.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:
            # 認証
            try:
                with open(self.KEY_FILE, "r", encoding="utf-8") as f:
                    stored = json.load(f)
            except (OSError, ValueError):
                messagebox.showerror(
                    "エラー", "鍵ファイルを読み込めませんでした")
                return
            if stored.get("version") != self.KEY_VERSION:
                messagebox.showerror(
                    "旧形式のデータです",
                    "この鍵ファイルは旧バージョン(ソルト無しの鍵導出)で"
                    "作られています。\n"
                    "現在の鍵導出方式では保存データを復号できません。\n"
                    "passwords.key と passwords.enc を削除してから、"
                    "マスターパスワードを作り直してください。\n"
                    "削除すると保存済みのパスワードは復元できません。"
                    "必要なら、手元に残っている旧バージョンの"
                    "app11.pyで先にログインして控えてから削除して"
                    "ください。")
                return
            hashed = hashlib.sha256((pw + stored["salt"]).encode()).hexdigest()
            if not hmac.compare_digest(hashed, stored["hash"]):
                messagebox.showerror("エラー", "パスワードが正しくありません")
                return
            try:
                kdf_salt = bytes.fromhex(stored["kdf_salt"])
            except (KeyError, ValueError):
                messagebox.showerror(
                    "エラー", "鍵ファイルのソルトが読み取れません")
                return
        else:
            # 新規作成
            if self.master_confirm and pw != self.master_confirm.get():
                messagebox.showerror("エラー", "パスワードが一致しません")
                return
            salt = secrets.token_hex(16)
            hashed = hashlib.sha256((pw + salt).encode()).hexdigest()
            kdf_salt = os.urandom(self.KDF_SALT_BYTES)
            with open(self.KEY_FILE, "w", encoding="utf-8") as f:
                json.dump({"version": self.KEY_VERSION, "hash": hashed,
                           "salt": salt, "kdf_salt": kdf_salt.hex(),
                           "kdf_iterations": self.KDF_ITERATIONS}, f)

        if CRYPTO_AVAILABLE:
            self.status_var.set("鍵を導出しています…")
            self.root.update_idletasks()
            self.fernet = Fernet(self._derive_key(pw, kdf_salt))
        del pw
        self.master_entry.delete(0, tk.END)
        if self.master_confirm:
            self.master_confirm.delete(0, tk.END)

        if not self._load_passwords():
            self.status_var.set("")
            return
        self._show_manager()

    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())

        self.lb = tk.Listbox(left, font=("Arial", 11),
                              selectbackground="#c62828",
                              selectforeground="white")
        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 _load_passwords(self):
        """保存データを読み込む。成功したら True を返す。"""
        self.entries = []
        if not os.path.exists(self.DATA_FILE):
            return True
        try:
            with open(self.DATA_FILE, "rb") as f:
                raw = f.read()
            if CRYPTO_AVAILABLE and self.fernet:
                raw = self.fernet.decrypt(raw)
            self.entries = json.loads(raw)
        except InvalidToken:
            self.fernet = None
            messagebox.showerror(
                "復号できません",
                "保存データを復号できませんでした。\n"
                "鍵ファイル(passwords.key)と暗号化データ(passwords.enc)の"
                "組み合わせが違う可能性があります。")
            return False
        except (OSError, ValueError):
            messagebox.showerror(
                "読み込みエラー", "保存データを読み込めませんでした")
            return False
        return True

    def _save_passwords(self):
        raw = json.dumps(self.entries, ensure_ascii=False).encode()
        if CRYPTO_AVAILABLE and self.fernet:
            raw = self.fernet.encrypt(raw)
        with open(self.DATA_FILE, "wb") as f:
            f.write(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', '')})")

    def _on_select(self, event):
        sel = self.lb.curselection()
        if not sel:
            return
        idx = sel[0]
        if idx < len(self._filtered):
            e = self._filtered[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", ""))
            self._selected_idx = self.entries.index(e)

    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):
        if not hasattr(self, "_selected_idx"):
            return
        self.entries[self._selected_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):
        if not hasattr(self, "_selected_idx"):
            return
        if messagebox.askyesno("確認", "このエントリを削除しますか?"):
            self.entries.pop(self._selected_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 = []
        self._show_login()


if __name__ == "__main__":
    root = tk.Tk()
    app = App11(root)
    root.mainloop()

5. コード解説

パスワードマネージャーのコードを、実際に書かれている実装に沿って解説します。

クラス設計とコンストラクタ

App11 クラスにアプリの全機能をまとめています(メソッド18個・全375行)。__init__ ではタイトル「パスワードマネージャー」とウィンドウサイズ 780x540 を設定します。あわせて状態を保持する変数(self.fernet)を初期化し、そのあと _build_ui()_check_crypto() の順に呼び出して初期状態を作ります。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

ウィジェット構成

画面は tk.Frame×8(ほかにループでも生成)・tk.Label×6(ほかにループでも生成)・ttk.Button×2(ほかにループでも生成)・ttk.Entry×3(ほかにループでも生成)・ttk.PanedWindowtk.Listboxttk.Scrollbarttk.LabelFrametk.Text(ループで生成)・ttk.Style(ループで生成) で構成しています。見出し付きの枠(LabelFrame)は「詳細・編集」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

イベント処理とボタンの接続

「🔒 ロック」ボタン(_lock())・ボタン(_authenticate())を command= で接続しています。ボタンはループ内で生成し、_toggle_pwd() に接続しています。また bind("<Return>", ...) から _authenticate()bind("<<ListboxSelect>>", ...) から _on_select() を呼べるようにイベントも登録しています。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

JSON への保存と読み込み

データは json.dump() でファイルへ保存します。起動時は json.load() で読み戻します。

※ 該当部分のコード本体は 「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 で ImportErrorInvalidTokenKeyErrorOSErrorValueError を捕捉しています。あわせて messagebox.askyesno()messagebox.showerror()messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。

※ 該当部分のコード本体は 「4. 完全なソースコード」 をご参照ください(重複表示を避けるため再掲を省略しています)。

6. ステップバイステップガイド

このアプリをゼロから自分で作る手順を解説します。コードをコピーするだけでなく、実際に手順を追って自分で書いてみましょう。

  1. 1
    ファイルを作成する

    新しいファイルを作成して app11.py と保存します。外部ライブラリ cryptography を使います。先に pip install cryptography を実行してください。

  2. 2
    クラスの骨格を作る

    App11 クラスを定義し、__init__ と、末尾の root = tk.Tk()mainloop() の最小構成を書いて、まず空のウィンドウが出ることを確認します。

  3. 3
    ウィンドウを設定する

    title("パスワードマネージャー")geometry("780x540")configure(bg="#f8f9fc") をコンストラクタで設定します。

  4. 4
    画面部品を並べる

    _build_ui() の中で、tk.Frame×8(ほかにループでも生成)・tk.Label×6(ほかにループでも生成)・ttk.Button×2(ほかにループでも生成)・ttk.Entry×3(ほかにループでも生成)・ttk.PanedWindowtk.Listboxttk.Scrollbarttk.LabelFrametk.Text(ループで生成)・ttk.Style(ループで生成) を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

  5. 5
    イベントを接続する

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_lock()_authenticate())につなぎます。ループ生成のボタンは command=self._toggle_pwd の形で接続します。bind("<Return>", ...)bind("<<ListboxSelect>>", ...) の登録も忘れずに。

  6. 6
    中心になるメソッドを実装する

    アプリの本体である _authenticate()(65行)・_on_select()(14行)・_add_entry()(14行)など を実装します。

  7. 7
    動作確認する

    python app11.py で起動し、各ボタンが反応すること・キー操作(<Return>)が効くことを確認します。

7. カスタマイズアイデア

基本機能を習得したら、以下のカスタマイズに挑戦してみましょう。少しずつ機能を追加することで、Pythonのスキルが飛躍的に向上します。

💡 ダークモードを追加する

bg色・fg色を辞書で管理し、ボタン1つでダークモード・ライトモードを切り替えられるようにしましょう。

💡 クラス定数を書き換えて動きを変える

データはクラス定数(DATA_FILEKEY_FILE)にまとまっています。中身を書き換えて動きが変わることを確かめましょう。複数の定数が対応関係にある場合は、セットで整合させる必要がある点に注意してください。

💡 入力履歴機能

以前の入力値を覚えておいてComboboxのドロップダウンで再選択できる履歴機能を追加しましょう。

8. よくある問題と解決法

❌ 文字のフォントが崩れる・見た目が違う

原因:このコードは font=("Noto Sans JP", ...) のようにフォント名を直接指定しています。環境にこのフォントが入っていないと、OS が代わりのフォントで表示するため、見本と見た目が変わることがあります。

解決法:動作には支障ありません。気になる場合は font 引数の名前を自分の OS に入っているフォントへ書き換えるか、font 引数を省略してください。

<Return> キーを押しても反応しない

原因:bind("<Return>", ...) はウィジェット単位で登録されるため、ウィンドウ(またはバインド先)にキーボードフォーカスが無いとイベントが届きません。

解決法:一度ウィンドウ内をクリックしてフォーカスを与えてから、キーを押してください。

❌ ウィンドウの大きさが画面に合わない

原因:geometry("780x540") の固定サイズで起動するためです。

解決法:この数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。このアプリは仕組みを学ぶための学習用です。実際のパスワードの保管には使わないでください(1章の注意も参照)。練習では test1 のような架空の名前と、その場で「🎲 生成」した値だけを入れてください。「➕ 追加」を押すと、同じフォルダに passwords.encpasswords.key が作られます(26-27行)。下の2問は画面の操作まわりを直すもので、鍵の作り方(_derive_key()・114-127行)とマスターパスワードの扱いは変えないでください。課題1・課題2に追加のライブラリは要りません(アプリ本体を暗号化ありで動かすには pip install cryptography が必要です)。

  1. 課題1:削除したあとにもう一度「🗑️ 削除」を押すと、選んでいない項目が消える

    削除に使う番号は、一覧で項目を選んだときに覚えます。削除したあとも、この番号は前のままです。選び直さずにもう一度押すと、その番号にずれ込んだ別の項目が消えます。

    期待結果:サービス名に test1test2test3 と入れて「➕ 追加」を3回押す(ステータス欄が 3 件)。一覧の test1 を選んで「🗑️ 削除」から「はい」を押すと 2 件 になる。一覧を選び直さずにもう一度「🗑️ 削除」から「はい」を押すと、確認だけで test2 が消えて 1 件 になる。直したあとは、2回目の「🗑️ 削除」では確認も出ず、件数も変わらない。

    合格条件

    • 選んだ項目を「🗑️ 削除」で消す動きは、今までどおり
    • 削除したあと選び直していないときは、「🗑️ 削除」で何も消えない
    • 削除したあと選び直していないときは、「✏️ 更新」でも中身が書き換わらない

    ヒント①(どこを触るか): 触るのは1か所です。_delete_entry()self.entries.pop(self._selected_idx)(347行)の直後。
    ヒント②(使うもの): 覚えている番号を捨てれば、次の操作は入口で止まります。_delete_entry()_update_entry() はどちらも hasattr(self, "_selected_idx") で守られているので(333行・344行)、del self._selected_idx の1行が両方に効きます。
    つまずきやすい点: 残り1件を選んで削除し一覧を空にしてから、選び直さずにもう一度押すと IndexError: pop from empty list が出ます。_on_select()self.entries.index(e) で番号を求めるため(315行)、同じ内容の項目が2つあると先に見つかったほうの番号です。検索欄で絞ると一覧の並びは self._filtered のものになりますが、番号は self.entries 側に直してから覚えています(307-315行)。

  2. 課題2:コピーしたパスワードがクリップボードに残り続ける

    「📋 コピー」はパスワードをクリップボードへ入れます。消す処理はどこにもないため、別の物をコピーするまで残ったままです。ほかのアプリへうっかり貼り付けてしまう事故のもとになります。

    期待結果:エントリを選んで「📋 コピー」を押し、メモ帳などに貼り付けられることを確かめる。ステータス欄は パスワードをクリップボードにコピーしました のまま変わらない。しばらく待ってからもう一度貼り付けても、同じ値が貼れる。直したあとは、決めた時間(30秒など)が過ぎるとクリップボードが空になり、貼り付けても何も入らない。

    合格条件

    • 「📋 コピー」の直後は、今までどおり貼り付けられる
    • 決めた時間が過ぎると、クリップボードからパスワードが消える
    • そのあいだに別のアプリでコピーしたときは、そちらを消さない

    ヒント①(どこを触るか): 触るのは2か所です。_copy_pwd()(361-364行)の最後と、消すためのメソッドを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() は詳細欄が空でも押せて、空の文字列をクリップボードに入れます(363行)。予約が何本も重ならないよう、after_cancel() で前の予約を取り消す形にしておくと確実です。

  3. 課題3:新しいボタンを追加する

    既存のボタンと同じ書き方で新しいボタンを1つ足し、押されたときに動くメソッドを自作してみましょう。ボタンの作成と command= の接続がセットで身に付きます。

🚀
次に挑戦するアプリ

このアプリをマスターしたら、次のアプリにも挑戦してみましょう。

🐛
エラーが出て動かないときは

写経中に赤いエラー文が出たら、Pythonエラー一覧&解決法(英語メッセージ逆引き)で原因と直し方をすぐ確認できます。

📖
次のレベルへ進む参考書

このレベルのアプリが作れたら、入門書の次の「作るための本」へ進む時期です。実践におすすめのPython本(当サイトの参考書ランキング総合3〜4位)で自動化とコードの書き方の2冊を、その直後の用途別専門書の節でデータ分析・Web・AIの本を比較しています。