中級者向け 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.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 でログインすると、保存データを引き継いだまま新しい形式へ自動で移ります。次の順に進めてください。

  1. 念のため、passwords.key と passwords.enc を別のフォルダにコピーしてバックアップします。前の版をデモモード(cryptography なし)で使っていた方は、ここで pip install cryptography を実行してください(入っていないと新しい版は起動しません)。
  2. 新しい app11.py を同じフォルダに置き、いつものマスターパスワードでログインします。「旧形式の鍵ファイルを新形式に移しました(N 件)」と表示されれば、データはそのまま新しい形式に移っています。パスワードが違うときはエラーになり、ファイルは書き換えません。
  3. 一覧で中身を確認できたら、バックアップした passwords.key を削除してください。旧形式の照合用ハッシュが残っているからです。前の版をデモモードで使っていた方は、バックアップの passwords.enc も平文(暗号化されていない JSON)なので削除してください。

最初期の版(ソルト無しの SHA-256 で鍵を作っていた版・鍵ファイルに version の項目が無いもの)の passwords.key は、新しい版では開けません(移行機能はありません)。アプリは「旧形式のデータです」と案内し、ファイルを変更せずに止まります。手元に残っている旧バージョンの app11.py(このページの4章で使っているファイル名)でログインして必要な項目を控えてから、passwords.key と passwords.enc を別の場所へ移し、マスターパスワードを作り直してください。控えた項目は、作り直したあとに入れ直します。旧バージョンの app11.py が手元に無い場合、このページでは旧版を配布していないため、この版では保存データを取り出せません(マスターパスワードを忘れた場合と同じ扱いになります)。

使用ライブラリは tkinter(標準ライブラリ)・cryptography で、難易度は ★★★ です。

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

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

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

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 が入っていない状態で起動すると、「cryptography が必要です。pip install cryptography でインストールしてから、もう一度起動してください。」というエラーダイアログを出して終了します。暗号化できない状態で、平文のまま保存することはありません。起動に使う Python が複数ある場合は、python -m pip install cryptography のように、起動に使う Python と同じものでインストールしてください。

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 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. 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.PanedWindow・tk.Listbox・ttk.Scrollbar・ttk.LabelFrame・tk.Text(ループで生成)・ttk.Style(ループで生成) を作って配置します(掲載コードと同じ並び順で書くとレイアウトが一致します)。まず表示だけ確認しましょう。

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

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

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

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

  7. 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. 課題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. 課題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. 課題3:新しいボタンを追加する

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

🚀
次に挑戦するアプリ

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

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

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

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

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