中級者向け No.32

暗号化・復号ツール

テキストとファイルをAES(Fernet)で暗号化・復号し、RSA公開鍵暗号・ハッシュ・Base64も試せるツール。cryptographyライブラリで暗号化の仕組みを学ぶ学習用ツールです。

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

1. アプリ概要

テキストとファイルをAES(Fernet)で暗号化・復号し、RSA公開鍵暗号・ハッシュ・Base64も試せるツール。cryptographyライブラリで暗号化の仕組みを学ぶ学習用ツールです。

⚠️
このコードは学習用です(ソルトの扱いだけ標準的な方式に直しました)

パスワードから鍵を作る処理は PBKDF2HMAC(SHA-256)を使います。ソルトは暗号化のたびに os.urandom(16) で作り直し、暗号文の先頭に埋め込みますb"PLv2" の目印 4 バイト+ソルト 16 バイト+Fernet トークンをまとめて urlsafe base64 で文字列化)。復号時は先頭のソルトを読み出して同じ鍵を導出するので、同じパスワードでも暗号化のたびに違う暗号文になります(ソルト自体は秘密にしなくて構いません)。公式ドキュメントもソルトを「128 ビット(16 バイト)以上のランダム値」とし、例では os.urandom(16) を使っています。出典: cryptography 公式ドキュメント(Key derivation functions)(2026-09-09 確認)

ソルト以外は学習用のままです。反復回数は PBKDF2_ITERATIONS = 100000(10 万回)で、上記の公式ドキュメントの例(1,200,000 回)より少ない設定です。鍵やパスワードの保管方法、鍵の世代管理といった運用面も扱っていません。このコードをそのまま本番の機微情報の暗号化・パスワード管理に使わないでください。

旧バージョン(ソルトが b"pythonland_salt_" の固定値)で暗号化したデータは、このコードでは復号できません。移行機能は用意していません。ヘッダの無いデータを復号しようとすると「この形式は復号できません」という案内が出ます。旧データが必要な場合は、旧バージョンのコードを手元に残して復号してください。

ℹ️
ファイルの暗号化・復号の使い方(Fernet タブ)

手順は2ステップです。上のパスワード欄に入力してから「📁 ファイルを暗号化」を押し、ダイアログでファイルを選びます。同じフォルダに <元の名前>.enc が作られます。中身はテキストのときと同じ並び(b"PLv2" 4 バイト+ソルト 16 バイト+Fernet トークン)を、base64 に通さずそのまま書き出したものです。元のファイルは残ります。

「📂 ファイルを復号」は .enc を選ぶと、拡張子を外した名前で書き戻します。同じ名前のファイルがすでにあるときは上書き確認のダイアログが出ます。パスワードが違うと Fernet が InvalidToken を送出し、「パスワードが違うか、データが壊れています。」と表示してファイルは書き込みませんPLv2 のヘッダが無いファイル(旧形式など)も復号せず案内を出します。

扱えるサイズの上限は MAX_FILE_MB = 50 です。このコードはファイル全体を一度にメモリへ読み込むため、超えると「サイズ超過」で止まります。パスワードを忘れると .enc は復号できません。10KB のバイナリで暗号化→復号し、SHA-256 が元ファイルと一致すること・同じパスワードでも2回の暗号文が別物になること・誤ったパスワードで復号が失敗することを確認しています(Windows 11 / Python 3.12.10 / cryptography 48.0.0・2026-09-09)。

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

このアプリで学べること。テキストとファイルを AES(Fernet)で暗号化・復号し、RSA の公開鍵暗号・ハッシュ・Base64 を1つの画面で試せます。パスワードから鍵を作る PBKDF2、暗号化のたびに変わるソルト、パスワードを失うと復号できなくなること——暗号化の仕組みを手を動かして確かめる題材です。上の注記のとおり学習用なので、API キーやパスワードの保管そのものを任せる用途には使わないでください。cryptography ライブラリは AES を使う Fernet(AES-128-CBC+HMAC 認証)と RSA-OAEP を提供しており、既定パラメータをライブラリ側が用意してくれるので、危険な暗号処理を自前で実装せずに済みます。

暗号化ツールでつまずきやすい・間違えやすい点が3つあります。① AES のモードやパディングを自前で組むと脆弱になりがち: まずは Fernet(AES-128-CBC+HMAC 認証を安全に既定化したもの)を使うのが鉄則です。生の AES が必要なときだけ低レベル API へ進みます。② 鍵の管理が最大の弱点: 鍵をソースコードにハードコードしないこと。環境変数や鍵ファイル+OS の権限で保護し、鍵を失うと復号できなくなる点にも注意します。③ RSA は大きなデータを直接暗号化できない: 実務では「データ本体を AES で暗号化し、その AES 鍵だけを RSA で暗号化する」ハイブリッド方式を使います。

慣れたら、こう応用できます。反復回数を公式ドキュメントの例(1,200,000 回)まで引き上げる調整、大きなファイルを分割して読み書きするストリーム処理(このコードは全体をメモリに読むため上限 50MB です)、署名と検証などに発展できます。ファイルまるごとの暗号化とパスワードからの鍵導出は、このコードで実装済みです(範囲と限界は上の注記を参照)。標準ライブラリ以外の周辺ツールは Pythonライブラリ一覧 も参考にしてください。⚠️ 本ツールは暗号化の仕組みを学ぶための学習用です。機微情報の本番運用には、実績のある専用ソフト(パスワードマネージャー等)の利用をおすすめします。

暗号化・復号ツール 実行画面(Windows)
実行画面(Windows)
暗号化・復号ツール 実行画面(Linux Mint)
実行画面(Linux Mint)

2. 機能一覧

  • ループで生成するボタン群を _compute_hash() に接続
  • 「🔑 パスワードを使う」ボタン(_gen_fernet_key())・「🎲 ランダム生成」ボタン(_random_fernet_key())・「🔒 暗号化」ボタン(_fernet_encrypt())・「🔓 復号」ボタン(_fernet_decrypt())・「📋 出力をコピー」ボタン(_copy_output())・「📁 ファイルを暗号化」ボタン(_file_encrypt())・「📂 ファイルを復号」ボタン(_file_decrypt())・「🔑 キーペア生成」ボタン(_gen_rsa_keys())・「🔒 公開鍵で暗号化」ボタン(_rsa_encrypt())・「🔓 秘密鍵で復号」ボタン(_rsa_decrypt())・「参照」ボタン(_browse_hash_file())・「SHA256 計算」ボタン(_file_hash())・「▶ Base64 エンコード」ボタン(_b64_encode())・「◀ Base64 デコード」ボタン(_b64_decode())・「📋 コピー」ボタン(_copy_output())で操作
  • filedialog.askopenfilename() によるファイル選択ダイアログ
  • messagebox.askyesno()messagebox.showerror()messagebox.showinfo()messagebox.showwarning() によるダイアログ通知
  • ウィンドウはタイトル「暗号化・復号ツール」・サイズ 860x620 で起動

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
app32.py
import tkinter as tk
from tkinter import ttk, messagebox, filedialog
import os
import base64
import hashlib
import secrets

try:
    from cryptography.fernet import Fernet, InvalidToken
    from cryptography.hazmat.primitives import hashes, serialization
    from cryptography.hazmat.primitives.asymmetric import rsa, padding
    from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
    CRYPTO_AVAILABLE = True
except ImportError:
    CRYPTO_AVAILABLE = False
    InvalidToken = Exception  # 未インストール時のダミー(except節を書けるようにする)

# パスワード方式の暗号文フォーマット
#   ヘッダ4バイト + ソルト16バイト + Fernetトークン
#   テキストは全体を urlsafe base64 で文字列化、ファイルはこの並びをそのまま書き出す
FERNET_HEADER = b"PLv2"
SALT_SIZE = 16            # ソルトの長さ(cryptography 公式ドキュメントの例と同じ16バイト)
PBKDF2_ITERATIONS = 100000
MAX_FILE_MB = 50          # ファイル全体をメモリに読むので上限を決めておく
ENC_SUFFIX = ".enc"       # 暗号化したファイルに付ける拡張子


class App32:
    """暗号化・復号ツール"""

    def __init__(self, root):
        self.root = root
        self.root.title("暗号化・復号ツール")
        self.root.geometry("860x620")
        self.root.minsize(860, 620)
        self.root.configure(bg="#1a1a2e")
        self._fernet = None
        self._rsa_private = None
        self._rsa_public = None
        self._build_ui()

    def _build_ui(self):
        # ヘッダー
        header = tk.Frame(self.root, bg="#16213e", pady=8)
        header.pack(fill=tk.X)
        tk.Label(header, text="🔐 暗号化・復号ツール",
                 font=("Noto Sans JP", 13, "bold"),
                 bg="#16213e", fg="#e94560").pack(side=tk.LEFT, padx=12)

        if not CRYPTO_AVAILABLE:
            tk.Label(self.root,
                     text="⚠ cryptographyライブラリが未インストールです "
                          "(pip install cryptography)。",
                     bg="#fff3cd", fg="#856404", font=("Arial", 9),
                     anchor="w", padx=8).pack(fill=tk.X)

        notebook = ttk.Notebook(self.root)
        notebook.pack(fill=tk.BOTH, expand=True, padx=6, pady=6)

        # ── タブ1: Fernet(AES-128 CBC)──────────────────────────
        fernet_tab = tk.Frame(notebook, bg="#1a1a2e")
        notebook.add(fernet_tab, text="🔑 Fernet (AES)")
        self._build_fernet_tab(fernet_tab)

        # ── タブ2: RSA ──────────────────────────────────────────
        rsa_tab = tk.Frame(notebook, bg="#1a1a2e")
        notebook.add(rsa_tab, text="🔒 RSA")
        self._build_rsa_tab(rsa_tab)

        # ── タブ3: ハッシュ ─────────────────────────────────────
        hash_tab = tk.Frame(notebook, bg="#1a1a2e")
        notebook.add(hash_tab, text="🔎 ハッシュ")
        self._build_hash_tab(hash_tab)

        # ── タブ4: Base64 ───────────────────────────────────────
        b64_tab = tk.Frame(notebook, bg="#1a1a2e")
        notebook.add(b64_tab, text="📄 Base64")
        self._build_b64_tab(b64_tab)

        self.status_var = tk.StringVar(value="アルゴリズムを選択してください")
        tk.Label(self.root, textvariable=self.status_var,
                 bg="#16213e", fg="#858585", font=("Arial", 9),
                 anchor="w", padx=8).pack(fill=tk.X, side=tk.BOTTOM)

    # ── Fernet タブ ──────────────────────────────────────────────

    def _build_fernet_tab(self, parent):
        lbl_s = {"bg": "#1a1a2e", "fg": "#c9d1d9", "font": ("Arial", 10)}
        lbl_lf = {"bg": ttk.Style().lookup("TLabelframe", "background"), "fg": "#1f2933", "font": ("Arial", 10)}

        # キー設定
        key_f = ttk.LabelFrame(parent, text="キー設定", padding=8)
        key_f.pack(fill=tk.X, padx=8, pady=6)
        row1 = tk.Frame(key_f, bg=ttk.Style().lookup("TLabelframe", "background"))
        row1.pack(fill=tk.X)
        tk.Label(row1, text="パスワード:", **lbl_lf).pack(side=tk.LEFT)
        self.fernet_pw_var = tk.StringVar()
        ttk.Entry(row1, textvariable=self.fernet_pw_var, show="*",
                  width=30).pack(side=tk.LEFT, padx=6)
        ttk.Button(row1, text="🔑 パスワードを使う",
                   command=self._gen_fernet_key).pack(side=tk.LEFT, padx=4)
        ttk.Button(row1, text="🎲 ランダム生成",
                   command=self._random_fernet_key).pack(side=tk.LEFT, padx=4)

        row2 = tk.Frame(key_f, bg=ttk.Style().lookup("TLabelframe", "background"))
        row2.pack(fill=tk.X, pady=4)
        tk.Label(row2, text="Fernetキー:", **lbl_lf).pack(side=tk.LEFT)
        self.fernet_key_var = tk.StringVar()
        ttk.Entry(row2, textvariable=self.fernet_key_var,
                  width=60).pack(side=tk.LEFT, padx=6, fill=tk.X, expand=True)

        row3 = tk.Frame(key_f, bg=ttk.Style().lookup("TLabelframe", "background"))
        row3.pack(fill=tk.X)
        tk.Label(row3,
                 text="パスワード方式では暗号化のたびにソルト(16バイト)を作り、"
                      "暗号文の先頭に埋め込みます",
                 **lbl_lf).pack(side=tk.LEFT)

        # テキスト入出力
        io_f = ttk.PanedWindow(parent, orient=tk.HORIZONTAL)
        io_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)

        left = tk.Frame(io_f, bg="#1a1a2e")
        io_f.add(left, weight=1)
        tk.Label(left, text="入力テキスト", **lbl_s).pack(anchor="w", pady=2)
        self.fernet_input = tk.Text(left, bg="#0d1117", fg="#c9d1d9",
                                     font=("Courier New", 11), relief=tk.FLAT,
                                     height=12)
        self.fernet_input.pack(fill=tk.BOTH, expand=True)

        right = tk.Frame(io_f, bg="#1a1a2e")
        io_f.add(right, weight=1)
        tk.Label(right, text="出力テキスト", **lbl_s).pack(anchor="w", pady=2)
        self.fernet_output = tk.Text(right, bg="#0d1117", fg="#3fb950",
                                      font=("Courier New", 11), relief=tk.FLAT,
                                      height=12, state=tk.DISABLED)
        self.fernet_output.pack(fill=tk.BOTH, expand=True)

        btn_f = tk.Frame(parent, bg="#1a1a2e")
        btn_f.pack(pady=6)
        ttk.Button(btn_f, text="🔒 暗号化",
                   command=self._fernet_encrypt).pack(side=tk.LEFT, padx=8)
        ttk.Button(btn_f, text="🔓 復号",
                   command=self._fernet_decrypt).pack(side=tk.LEFT, padx=8)
        ttk.Button(btn_f, text="📋 出力をコピー",
                   command=lambda: self._copy_output(self.fernet_output)
                   ).pack(side=tk.LEFT, padx=8)

        self._build_file_frame(parent)

    def _build_file_frame(self, parent):
        """ファイルの暗号化・復号(パスワード方式)の操作エリアを作る"""
        lf_bg = ttk.Style().lookup("TLabelframe", "background")
        file_f = ttk.LabelFrame(parent, text="ファイルの暗号化・復号(パスワード方式)",
                                 padding=8)
        file_f.pack(fill=tk.X, padx=8, pady=6)
        row = tk.Frame(file_f, bg=lf_bg)
        row.pack(fill=tk.X)
        ttk.Button(row, text="📁 ファイルを暗号化",
                   command=self._file_encrypt).pack(side=tk.LEFT, padx=4)
        ttk.Button(row, text="📂 ファイルを復号",
                   command=self._file_decrypt).pack(side=tk.LEFT, padx=4)
        tk.Label(row,
                 text=f"上のパスワード欄を使います / {MAX_FILE_MB}MB まで / "
                      f"保存名は元の名前+{ENC_SUFFIX}",
                 bg=lf_bg, fg="#1f2933", font=("Arial", 10)).pack(side=tk.LEFT, padx=8)

    def _gen_fernet_key(self):
        """パスワード方式に切り替える。
        鍵はここでは作らず、暗号化・復号のたびにソルトから導出する。"""
        if not CRYPTO_AVAILABLE:
            messagebox.showerror("エラー", "cryptography が必要です")
            return
        pw = self.fernet_pw_var.get()
        if not pw:
            messagebox.showwarning("警告", "パスワードを入力してください")
            return
        self.fernet_key_var.set("")
        self._fernet = None
        self.status_var.set(
            "パスワード方式にしました(暗号化のたびにソルトを生成します)")

    def _derive_fernet_key(self, password, salt):
        """パスワードとソルトから Fernet キーを導出する(PBKDF2-HMAC-SHA256)"""
        kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32,
                          salt=salt, iterations=PBKDF2_ITERATIONS)
        return base64.urlsafe_b64encode(kdf.derive(password.encode("utf-8")))

    def _split_blob(self, blob):
        """バイト列がパスワード方式の並びなら (ソルト, トークン) を返す。違えば None"""
        head = len(FERNET_HEADER)
        if not blob.startswith(FERNET_HEADER):
            return None
        if len(blob) <= head + SALT_SIZE:
            return None
        return blob[head:head + SALT_SIZE], blob[head + SALT_SIZE:]

    def _split_envelope(self, text):
        """パスワード方式の暗号文なら (ソルト, トークン) を返す。違えば None"""
        try:
            blob = base64.urlsafe_b64decode(text.encode("ascii"))
        except Exception:
            return None
        return self._split_blob(blob)

    def _random_fernet_key(self):
        if not CRYPTO_AVAILABLE:
            messagebox.showerror("エラー", "cryptography が必要です")
            return
        key = Fernet.generate_key()
        self.fernet_key_var.set(key.decode())
        self._fernet = Fernet(key)
        self.status_var.set("ランダムFernetキーを生成しました")

    def _get_fernet(self):
        key_str = self.fernet_key_var.get().strip()
        if not key_str:
            messagebox.showwarning("警告", "キーを生成してください")
            return None
        if not CRYPTO_AVAILABLE:
            messagebox.showerror("エラー", "cryptography が必要です")
            return None
        try:
            return Fernet(key_str.encode())
        except Exception as e:
            messagebox.showerror("エラー", f"無効なキー: {e}")
            return None

    def _fernet_encrypt(self):
        text = self.fernet_input.get("1.0", tk.END).strip()
        if not text:
            return
        data = text.encode("utf-8")
        if self.fernet_key_var.get().strip():
            # Fernetキー方式: 入力欄のキーをそのまま使う(出力はFernetトークン)
            f = self._get_fernet()
            if not f:
                return
            try:
                token = f.encrypt(data)
                self._set_output(self.fernet_output, token.decode())
                self.status_var.set("暗号化しました(Fernetキー方式)")
            except Exception as e:
                messagebox.showerror("エラー", str(e))
            return
        pw = self.fernet_pw_var.get()
        if not pw:
            messagebox.showwarning("警告",
                                   "パスワードを入力するか、キーを生成してください")
            return
        if not CRYPTO_AVAILABLE:
            messagebox.showerror("エラー", "cryptography が必要です")
            return
        try:
            # ソルトは暗号化のたびに作り直し、暗号文の先頭に入れて持ち運ぶ
            salt = os.urandom(SALT_SIZE)
            f = Fernet(self._derive_fernet_key(pw, salt))
            blob = FERNET_HEADER + salt + f.encrypt(data)
            self._set_output(self.fernet_output,
                             base64.urlsafe_b64encode(blob).decode())
            self.status_var.set("暗号化しました(パスワード方式・ソルトを埋め込み)")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _fernet_decrypt(self):
        text = self.fernet_input.get("1.0", tk.END).strip()
        if not text:
            return
        parts = self._split_envelope(text)
        if parts is None:
            self._fernet_decrypt_with_key(text)
            return
        pw = self.fernet_pw_var.get()
        if not pw:
            messagebox.showwarning("警告", "暗号化に使ったパスワードを入力してください")
            return
        if not CRYPTO_AVAILABLE:
            messagebox.showerror("エラー", "cryptography が必要です")
            return
        salt, token = parts
        try:
            plain = Fernet(self._derive_fernet_key(pw, salt)).decrypt(token)
        except InvalidToken:
            messagebox.showerror("復号エラー",
                                 "パスワードが違うか、データが壊れています。")
            return
        except Exception as e:
            messagebox.showerror("復号エラー", str(e))
            return
        self._set_output(self.fernet_output, plain.decode("utf-8"))
        self.status_var.set("復号しました(パスワード方式)")

    def _fernet_decrypt_with_key(self, text):
        """ヘッダが無いデータの復号。Fernetキーが無ければ旧形式の可能性を案内する"""
        if not self.fernet_key_var.get().strip():
            messagebox.showwarning(
                "この形式は復号できません",
                "このデータにはソルトのヘッダがありません。\n"
                "ソルトを固定値にしていた旧バージョンの暗号文は、"
                "このバージョンでは復号できません(移行機能はありません)。\n"
                "Fernetキーで暗号化したデータなら、キーを入力してから復号してください。")
            return
        f = self._get_fernet()
        if not f:
            return
        try:
            plain = f.decrypt(text.encode())
        except InvalidToken:
            messagebox.showerror("復号エラー",
                                 "キーが違うか、データが壊れています。")
            return
        except Exception as e:
            messagebox.showerror("復号エラー", str(e))
            return
        self._set_output(self.fernet_output, plain.decode("utf-8"))
        self.status_var.set("復号しました(Fernetキー方式)")

    # ── ファイルの暗号化・復号 ────────────────────────────────────

    def _file_password(self):
        """ファイル操作で使うパスワードを返す。使えないときは None"""
        if not CRYPTO_AVAILABLE:
            messagebox.showerror("エラー", "cryptography が必要です")
            return None
        pw = self.fernet_pw_var.get()
        if not pw:
            messagebox.showwarning("警告",
                                   "上の「パスワード」欄に入力してください")
            return None
        return pw

    def _read_limited(self, path):
        """上限サイズ以内のファイルだけ読み込む。超えていれば None"""
        size = os.path.getsize(path)
        if size > MAX_FILE_MB * 1024 * 1024:
            messagebox.showerror(
                "サイズ超過",
                f"{MAX_FILE_MB}MB を超えるファイルは扱えません"
                f"(選んだファイルは {size / 1024 / 1024:.1f}MB)。\n"
                "このコードはファイル全体を一度にメモリへ読み込む実装のためです。")
            return None
        with open(path, "rb") as f:
            return f.read()

    def _confirm_overwrite(self, path):
        """保存先がすでにある場合だけ上書き確認を出す"""
        if not os.path.exists(path):
            return True
        return messagebox.askyesno(
            "上書きの確認",
            f"{os.path.basename(path)} はすでにあります。上書きしますか?")

    def _file_encrypt(self):
        pw = self._file_password()
        if not pw:
            return
        path = filedialog.askopenfilename(title="暗号化するファイルを選択")
        if not path:
            return
        data = self._read_limited(path)
        if data is None:
            return
        out_path = path + ENC_SUFFIX
        if not self._confirm_overwrite(out_path):
            return
        try:
            # テキストと同じ形式。ファイルでは base64 にせずそのまま書き出す
            salt = os.urandom(SALT_SIZE)
            f = Fernet(self._derive_fernet_key(pw, salt))
            with open(out_path, "wb") as out:
                out.write(FERNET_HEADER + salt + f.encrypt(data))
        except Exception as e:
            messagebox.showerror("エラー", str(e))
            return
        messagebox.showinfo("暗号化しました", f"保存先:\n{out_path}")
        self.status_var.set(
            f"ファイルを暗号化しました: {os.path.basename(out_path)}")

    def _file_decrypt(self):
        pw = self._file_password()
        if not pw:
            return
        path = filedialog.askopenfilename(
            title="復号するファイルを選択",
            filetypes=[("暗号化ファイル", "*" + ENC_SUFFIX),
                       ("すべてのファイル", "*.*")])
        if not path:
            return
        blob = self._read_limited(path)
        if blob is None:
            return
        parts = self._split_blob(blob)
        if parts is None:
            messagebox.showwarning(
                "この形式は復号できません",
                "先頭にソルトのヘッダがありません。\n"
                "このツールで暗号化したファイル(拡張子 .enc)を選んでください。\n"
                "ソルトを固定値にしていた旧バージョンのデータは復号できません。")
            return
        salt, token = parts
        try:
            plain = Fernet(self._derive_fernet_key(pw, salt)).decrypt(token)
        except InvalidToken:
            messagebox.showerror("復号エラー",
                                 "パスワードが違うか、データが壊れています。")
            return
        except Exception as e:
            messagebox.showerror("復号エラー", str(e))
            return
        if path.endswith(ENC_SUFFIX):
            out_path = path[:-len(ENC_SUFFIX)]      # 元の名前に戻す
        else:
            out_path = path + ".dec"
        if not self._confirm_overwrite(out_path):
            return
        try:
            with open(out_path, "wb") as out:
                out.write(plain)
        except Exception as e:
            messagebox.showerror("エラー", str(e))
            return
        messagebox.showinfo("復号しました", f"保存先:\n{out_path}")
        self.status_var.set(
            f"ファイルを復号しました: {os.path.basename(out_path)}")

    # ── RSA タブ ──────────────────────────────────────────────────

    def _build_rsa_tab(self, parent):
        lbl_s = {"bg": "#1a1a2e", "fg": "#c9d1d9", "font": ("Arial", 10)}

        key_f = ttk.LabelFrame(parent, text="RSAキーペア生成", padding=8)
        key_f.pack(fill=tk.X, padx=8, pady=6)
        btn_row = tk.Frame(key_f, bg=ttk.Style().lookup("TLabelframe", "background"))
        btn_row.pack(fill=tk.X)
        tk.Label(btn_row, text="鍵長:", bg=btn_row.cget("bg"),
                 fg="#1f2933").pack(side=tk.LEFT)
        self.rsa_bits_var = tk.StringVar(value="2048")
        ttk.Combobox(btn_row, textvariable=self.rsa_bits_var,
                     values=["1024", "2048", "4096"],
                     state="readonly", width=8).pack(side=tk.LEFT, padx=6)
        ttk.Button(btn_row, text="🔑 キーペア生成",
                   command=self._gen_rsa_keys).pack(side=tk.LEFT, padx=8)

        paned = ttk.PanedWindow(key_f, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.X, pady=4)
        for title, attr in [("公開鍵 (暗号化用)", "rsa_pub_text"),
                             ("秘密鍵 (復号用)", "rsa_priv_text")]:
            f = tk.Frame(paned, bg="#1a1a2e")
            paned.add(f, weight=1)
            tk.Label(f, text=title, **lbl_s).pack(anchor="w")
            txt = tk.Text(f, bg="#0d1117", fg="#c9d1d9",
                          font=("Courier New", 8), height=6, state=tk.DISABLED)
            txt.pack(fill=tk.X)
            setattr(self, attr, txt)

        io_f = ttk.PanedWindow(parent, orient=tk.HORIZONTAL)
        io_f.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)

        left = tk.Frame(io_f, bg="#1a1a2e")
        io_f.add(left, weight=1)
        tk.Label(left, text="入力テキスト", **lbl_s).pack(anchor="w")
        self.rsa_input = tk.Text(left, bg="#0d1117", fg="#c9d1d9",
                                  font=("Courier New", 11), height=8)
        self.rsa_input.pack(fill=tk.BOTH, expand=True)

        right = tk.Frame(io_f, bg="#1a1a2e")
        io_f.add(right, weight=1)
        tk.Label(right, text="出力テキスト", **lbl_s).pack(anchor="w")
        self.rsa_output = tk.Text(right, bg="#0d1117", fg="#3fb950",
                                   font=("Courier New", 11), height=8,
                                   state=tk.DISABLED)
        self.rsa_output.pack(fill=tk.BOTH, expand=True)

        btn_f = tk.Frame(parent, bg="#1a1a2e")
        btn_f.pack(pady=6)
        ttk.Button(btn_f, text="🔒 公開鍵で暗号化",
                   command=self._rsa_encrypt).pack(side=tk.LEFT, padx=8)
        ttk.Button(btn_f, text="🔓 秘密鍵で復号",
                   command=self._rsa_decrypt).pack(side=tk.LEFT, padx=8)

    def _gen_rsa_keys(self):
        if not CRYPTO_AVAILABLE:
            messagebox.showerror("エラー", "cryptography が必要です")
            return
        bits = int(self.rsa_bits_var.get())
        self.status_var.set(f"RSA {bits}bitキーを生成中...")
        self.root.update()
        try:
            self._rsa_private = rsa.generate_private_key(
                public_exponent=65537, key_size=bits)
            self._rsa_public = self._rsa_private.public_key()
            priv_pem = self._rsa_private.private_bytes(
                serialization.Encoding.PEM,
                serialization.PrivateFormat.TraditionalOpenSSL,
                serialization.NoEncryption()).decode()
            pub_pem = self._rsa_public.public_bytes(
                serialization.Encoding.PEM,
                serialization.PublicFormat.SubjectPublicKeyInfo).decode()
            self._set_output(self.rsa_pub_text, pub_pem)
            self._set_output(self.rsa_priv_text, priv_pem)
            self.status_var.set(f"RSA {bits}bitキーペアを生成しました")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _rsa_encrypt(self):
        if not CRYPTO_AVAILABLE or not self._rsa_public:
            messagebox.showwarning("警告", "先にキーペアを生成してください")
            return
        text = self.rsa_input.get("1.0", tk.END).strip()
        if not text:
            return
        try:
            cipher = self._rsa_public.encrypt(
                text.encode("utf-8"),
                padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()),
                             algorithm=hashes.SHA256(), label=None))
            self._set_output(self.rsa_output, base64.b64encode(cipher).decode())
            self.status_var.set("RSA暗号化しました")
        except Exception as e:
            messagebox.showerror("エラー", str(e))

    def _rsa_decrypt(self):
        if not CRYPTO_AVAILABLE or not self._rsa_private:
            messagebox.showwarning("警告", "先にキーペアを生成してください")
            return
        text = self.rsa_input.get("1.0", tk.END).strip()
        if not text:
            return
        try:
            cipher = base64.b64decode(text)
            plain = self._rsa_private.decrypt(
                cipher,
                padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()),
                             algorithm=hashes.SHA256(), label=None))
            self._set_output(self.rsa_output, plain.decode("utf-8"))
            self.status_var.set("RSA復号しました")
        except Exception as e:
            messagebox.showerror("復号エラー", str(e))

    # ── ハッシュ タブ ──────────────────────────────────────────────

    def _build_hash_tab(self, parent):
        lbl_s = {"bg": "#1a1a2e", "fg": "#c9d1d9", "font": ("Arial", 10)}
        tk.Label(parent, text="テキスト:", **lbl_s).pack(anchor="w", padx=8, pady=4)
        self.hash_input = tk.Text(parent, bg="#0d1117", fg="#c9d1d9",
                                   font=("Courier New", 11), height=6,
                                   relief=tk.FLAT)
        self.hash_input.pack(fill=tk.X, padx=8)

        btn_f = tk.Frame(parent, bg="#1a1a2e")
        btn_f.pack(fill=tk.X, padx=8, pady=6)
        for algo in ["MD5", "SHA1", "SHA256", "SHA512", "SHA3-256"]:
            ttk.Button(btn_f, text=algo,
                       command=lambda a=algo: self._compute_hash(a)
                       ).pack(side=tk.LEFT, padx=4)

        # ファイルハッシュ
        file_f = ttk.LabelFrame(parent, text="ファイルハッシュ", padding=6)
        file_f.pack(fill=tk.X, padx=8, pady=4)
        file_row = tk.Frame(file_f, bg=ttk.Style().lookup("TLabelframe", "background"))
        file_row.pack(fill=tk.X)
        self.hash_file_var = tk.StringVar()
        ttk.Entry(file_row, textvariable=self.hash_file_var,
                  width=40).pack(side=tk.LEFT, fill=tk.X, expand=True)
        ttk.Button(file_row, text="参照",
                   command=self._browse_hash_file).pack(side=tk.LEFT, padx=4)
        ttk.Button(file_row, text="SHA256 計算",
                   command=self._file_hash).pack(side=tk.LEFT, padx=4)

        tk.Label(parent, text="ハッシュ結果:", **lbl_s).pack(anchor="w", padx=8, pady=4)
        self.hash_output = tk.Text(parent, bg="#0d1117", fg="#3fb950",
                                    font=("Courier New", 11), height=10,
                                    state=tk.DISABLED, relief=tk.FLAT)
        self.hash_output.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)

    def _compute_hash(self, algo):
        text = self.hash_input.get("1.0", tk.END).strip()
        data = text.encode("utf-8")
        algo_map = {
            "MD5": hashlib.md5,
            "SHA1": hashlib.sha1,
            "SHA256": hashlib.sha256,
            "SHA512": hashlib.sha512,
            "SHA3-256": hashlib.sha3_256,
        }
        h = algo_map[algo](data).hexdigest()
        result = f"[{algo}]\n{h}\n\n"
        self.hash_output.config(state=tk.NORMAL)
        self.hash_output.insert(tk.END, result)
        self.hash_output.config(state=tk.DISABLED)
        self.status_var.set(f"{algo}: {h[:20]}...")

    def _browse_hash_file(self):
        path = filedialog.askopenfilename()
        if path:
            self.hash_file_var.set(path)

    def _file_hash(self):
        path = self.hash_file_var.get()
        if not path or not os.path.exists(path):
            messagebox.showwarning("警告", "ファイルを選択してください")
            return
        h = hashlib.sha256()
        with open(path, "rb") as f:
            for chunk in iter(lambda: f.read(65536), b""):
                h.update(chunk)
        result = f"[SHA256] {os.path.basename(path)}\n{h.hexdigest()}\n\n"
        self.hash_output.config(state=tk.NORMAL)
        self.hash_output.insert(tk.END, result)
        self.hash_output.config(state=tk.DISABLED)

    # ── Base64 タブ ──────────────────────────────────────────────

    def _build_b64_tab(self, parent):
        lbl_s = {"bg": "#1a1a2e", "fg": "#c9d1d9", "font": ("Arial", 10)}
        paned = ttk.PanedWindow(parent, orient=tk.HORIZONTAL)
        paned.pack(fill=tk.BOTH, expand=True, padx=8, pady=8)

        left = tk.Frame(paned, bg="#1a1a2e")
        paned.add(left, weight=1)
        tk.Label(left, text="入力", **lbl_s).pack(anchor="w")
        self.b64_input = tk.Text(left, bg="#0d1117", fg="#c9d1d9",
                                  font=("Courier New", 11), relief=tk.FLAT)
        self.b64_input.pack(fill=tk.BOTH, expand=True)

        right = tk.Frame(paned, bg="#1a1a2e")
        paned.add(right, weight=1)
        tk.Label(right, text="出力", **lbl_s).pack(anchor="w")
        self.b64_output = tk.Text(right, bg="#0d1117", fg="#3fb950",
                                   font=("Courier New", 11), relief=tk.FLAT,
                                   state=tk.DISABLED)
        self.b64_output.pack(fill=tk.BOTH, expand=True)

        btn_f = tk.Frame(parent, bg="#1a1a2e")
        btn_f.pack(pady=6)
        ttk.Button(btn_f, text="▶ Base64 エンコード",
                   command=self._b64_encode).pack(side=tk.LEFT, padx=8)
        ttk.Button(btn_f, text="◀ Base64 デコード",
                   command=self._b64_decode).pack(side=tk.LEFT, padx=8)
        ttk.Button(btn_f, text="📋 コピー",
                   command=lambda: self._copy_output(self.b64_output)
                   ).pack(side=tk.LEFT, padx=8)

    def _b64_encode(self):
        text = self.b64_input.get("1.0", tk.END).strip()
        encoded = base64.b64encode(text.encode("utf-8")).decode()
        self._set_output(self.b64_output, encoded)
        self.status_var.set("Base64エンコードしました")

    def _b64_decode(self):
        text = self.b64_input.get("1.0", tk.END).strip()
        try:
            decoded = base64.b64decode(text).decode("utf-8")
            self._set_output(self.b64_output, decoded)
            self.status_var.set("Base64デコードしました")
        except Exception as e:
            messagebox.showerror("エラー", f"デコード失敗: {e}")

    # ── ユーティリティ ────────────────────────────────────────────

    def _set_output(self, widget, text):
        widget.config(state=tk.NORMAL)
        widget.delete("1.0", tk.END)
        widget.insert("1.0", text)
        widget.config(state=tk.DISABLED)

    def _copy_output(self, widget):
        text = widget.get("1.0", tk.END).strip()
        self.root.clipboard_clear()
        self.root.clipboard_append(text)
        self.status_var.set("クリップボードにコピーしました")


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

5. コード解説

暗号化・復号ツールのコードを、実際に書かれている実装に沿って解説します。

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

App32 クラスにアプリの全機能をまとめています(メソッド31個・全677行)。__init__ ではタイトル「暗号化・復号ツール」とウィンドウサイズ 860x620 を設定します。あわせて状態を保持する変数(self._fernetself._rsa_privateself._rsa_public)を初期化し、最後に _build_ui() で画面を組み立てます。

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

ウィジェット構成

画面は tk.Frame×21(ほかにループでも生成)・tk.Label×16(ほかにループでも生成)・ttk.Notebookttk.Style×7・ttk.LabelFrame×4・ttk.Entry×3・ttk.Button×15(ほかにループでも生成)・ttk.PanedWindow×4・tk.Text×8(ほかにループでも生成)・ttk.Combobox で構成しています。見出し付きの枠(LabelFrame)は「キー設定」「ファイルの暗号化・復号(パスワード方式)」「RSAキーペア生成」「ファイルハッシュ」のラベルでエリアを区切っています。PanedWindow を使っているので、ペインの境界をマウスでドラッグして幅を変えられます。

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

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

「🔑 パスワードを使う」ボタン(_gen_fernet_key())・「🎲 ランダム生成」ボタン(_random_fernet_key())・「🔒 暗号化」ボタン(_fernet_encrypt())・「🔓 復号」ボタン(_fernet_decrypt())・「📋 出力をコピー」ボタン(_copy_output())・「📁 ファイルを暗号化」ボタン(_file_encrypt())・「📂 ファイルを復号」ボタン(_file_decrypt())・「🔑 キーペア生成」ボタン(_gen_rsa_keys())・「🔒 公開鍵で暗号化」ボタン(_rsa_encrypt())・「🔓 秘密鍵で復号」ボタン(_rsa_decrypt())・「参照」ボタン(_browse_hash_file())・「SHA256 計算」ボタン(_file_hash())・「▶ Base64 エンコード」ボタン(_b64_encode())・「◀ Base64 デコード」ボタン(_b64_decode())・「📋 コピー」ボタン(_copy_output())を command= で接続しています。ボタンはループ内で command=lambda a=algo: self._compute_hash(a) の形で生成しています。既定引数でループ変数を固定するのがポイントで、これを書かないと全ボタンが最後の値で動いてしまいます。

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

例外処理

try-except で ExceptionImportErrorInvalidToken を捕捉しています。あわせて messagebox.askyesno()messagebox.showerror()messagebox.showinfo()messagebox.showwarning() のダイアログで、ユーザーへの通知・確認を行います。

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

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

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

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

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

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

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

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

    title("暗号化・復号ツール")geometry("860x620")configure(bg="#1a1a2e")minsize(860, 620) をコンストラクタで設定します。

  4. 4
    画面部品を並べる

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

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

    「2. 機能一覧」で挙げた各ボタンを command= で対応するメソッド(_gen_fernet_key()_random_fernet_key()_fernet_encrypt()_fernet_decrypt()_copy_output()_file_encrypt()_file_decrypt()_gen_rsa_keys()_rsa_encrypt()_rsa_decrypt()_browse_hash_file()_file_hash()_b64_encode()_b64_decode()_copy_output())につなぎます。ループ生成のボタンは command=lambda a=algo: self._compute_hash(a) の形で接続します。

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

    アプリの本体である _file_decrypt()(46行)・_fernet_encrypt()(35行)・_fernet_decrypt()(27行)など を実装します。

  7. 7
    動作確認する

    python app32.py で起動し、各ボタンが反応することを確認します。

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

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

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

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

💡 配色をまとめて管理する

背景色 #1a1a2e をはじめ、色コードが各所に直書きされています。色を1つの辞書にまとめると、テーマの切り替えが1か所の変更で済むようになります。

💡 画面の文言を自分好みに変える

ラベルやボタンの文言を自分の好みに書き換えてみましょう。文言を変えるだけでも、どのコードがどの表示に対応しているかが立体的に見えてきます。

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

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

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

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

❌ 写経したら全部のボタンが同じ動きになる

原因:ループでボタンを作るとき、このコードは command=lambda a=algo: self._compute_hash(a) のように既定引数でループ変数を固定しています。これを省略してループ変数を直接書くと、ループ終了後の最後の値が全ボタンで共有されてしまいます(Python のクロージャの有名な罠です)。

解決法:掲載コードのとおり、lambda の既定引数でその時点の値を渡してください。

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

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

解決法:geometry()minsize() の両方の数値を書き換えて起動サイズを調整してください。なお、このコードにはウィンドウサイズを固定する設定(resizable の指定)が無いため、ウィンドウの端をドラッグしたサイズ変更は既定どおり可能です。ただし minsize(860, 620) を指定しているため、起動時の大きさより小さくは縮められません(縮めると画面部品が表示されなくなるのを防ぐためです)。

9. 練習問題

アプリの理解を深めるための練習問題です。気になるものから挑戦してみてください(課題3は定型の発展課題です)。この課題で保存する鍵は暗号化されない平文です。試すときは大事なファイルではなくコピーを使い、作った鍵ファイルは確認が済んだら削除してください。

  1. 課題1:RSAの鍵ペアをファイルに保存する

    RSA タブで作った鍵は画面に出るだけで、アプリを閉じると消えます。公開鍵と秘密鍵を PEM ファイルとして保存できるようにしてください。保存した鍵を読み込む機能は、今回は作らなくて構いません。

    期待結果:キーペアを作ってから「公開鍵を保存」を押すと、選んだ場所に -----BEGIN PUBLIC KEY----- から始まるファイルができる。

    合格条件

    • 鍵を作る前に押したときは、警告が出るだけでファイルはできない
    • 保存ダイアログでキャンセルしたときは、何も書き込まれない
    • 秘密鍵のファイルは -----BEGIN RSA PRIVATE KEY----- から始まる

    ヒント①(どこを触るか): 触るのは2か所です。ボタンは _build_rsa_tab() の末尾で「🔒 公開鍵で暗号化」を並べている btn_f に足します。保存の処理は新しいメソッドに分けて書いてください。鍵の有無は self._rsa_public で判定できます。
    ヒント②(使うもの): 画面に出ている PEM は self.rsa_pub_textself.rsa_priv_text から get("1.0", tk.END) で取り出せます。保存先の選択は filedialog.asksaveasfilename(defaultextension=".pem") を使います(filedialog は先頭で読み込み済み)。
    つまずきやすい点: 秘密鍵は serialization.NoEncryption() で書き出されており、保存したファイルはそのまま読める平文です。共有フォルダやリポジトリには置かないでください。asksaveasfilename() はキャンセルすると空文字列を返すので、先に if not path: return を書かないとエラーになります。

  2. 課題2:ファイルのハッシュを選んだアルゴリズムで計算する

    ハッシュタブの「SHA256 計算」は、上のボタンで何を選んでも SHA256 で計算します。直前に押したアルゴリズムを、ファイル側でも使えるようにしてください。

    期待結果:「SHA512」を押してからファイルを選んで計算すると、結果に [SHA512] と128文字のハッシュが出る(いまは [SHA256] の64文字)。

    合格条件

    • 一度もアルゴリズムを押していないときは、これまでどおり SHA256 で計算される
    • 表示される [アルゴリズム名] と実際の桁数が食い違わない
    • 大きなファイルを 65536 バイトずつ読む今の作りを変えない

    ヒント①(どこを触るか): 触るのは3か所です。_compute_hash() の中にある algo_map をクラス直下の定数へ移し、選ばれた名前を属性に控えます。_file_hash() では hashlib.sha256() の代わりに、その名前から計算器を作ります。既定値は __init__() に置いてください。
    ヒント②(使うもの): 辞書には hashlib.md5 のように関数そのものを入れます。hashlib.md5() と括弧を付けると計算器の実体が1つできてしまい、呼ぶたびに前の内容が残ります。名前から作るときは 辞書[名前]() の形にしてください。
    つまずきやすい点: _file_hash() の結果は f"[SHA256] ..." と文字列に直接書かれています。ここを変数に替えないと、中身は SHA512 なのに [SHA256] と表示されます。ハッシュは暗号化ではないため、元に戻せない点も押さえておいてください。なお MD5 と SHA1 は、同じ値になる別データ(衝突)を作る方法が知られています。改ざん検知や署名には使わず、この課題では「選べることを確かめる」目的にとどめてください。

  3. 課題3:キー操作に対応させる

    ボタンから呼んでいる _gen_fernet_key() を、キーボードのキーからも呼べるようにしてみましょう。tkinter では bind を使ってキーイベントとメソッドを結び付けられます。

🚀
次に挑戦するアプリ

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

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

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

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

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