コンテンツへスキップ

対称鍵暗号(Fernet)でローカルに認証情報を安全に保存する仕組み

デスクトップアプリでサーバーのSSHパスワードやAPIキーを扱う場合、毎回入力させるのはユーザー体験として現実的でない。かといって設定ファイルに平文で保存すると、そのファイルがバックアップに含まれたり、別のツールに共有されたりした瞬間に認証情報が漏れる。ここでは、この種の「ローカルディスクに認証情報を安全に持たせる」問題に対して、対称鍵暗号(共通鍵暗号)がどう使われるかを、Pythonのcryptographyライブラリが提供するFernetを題材に整理する。

補足: 対称鍵暗号とは、暗号化と復号に同じ鍵を使う方式のこと。SSH鍵の種類(RSA / ED25519 / ECDSA)はどう違い、どれを選ぶべきかで扱ったSSH鍵(公開鍵と秘密鍵のペアを使う非対称鍵暗号)とは仕組みが異なる。非対称鍵暗号は「離れた2者間で秘密の鍵を共有せずに認証・通信する」ことを目的とするのに対し、対称鍵暗号は「同じプログラムが後で自分自身のために読み書きする」ような用途に向いている。

なぜ対称鍵で十分なのか

SSH接続やAPI通信では、通信相手が別のマシン・別の主体であるため、鍵を安全に配送する手段が必要になり、非対称鍵暗号が有効に機能する。一方、今回のようにアプリが自分のローカル設定を暗号化して保存し、後で自分自身が復号して使うだけのケースでは、鍵を他者と共有する必要がない。復号する主体と暗号化する主体が同じプログラムなので、単一の鍵を安全な場所に1つ保管しておけば足りる。対称鍵暗号は計算量的にも軽く、この種の「自分専用のロッカーに鍵をかける」用途に適している。

Fernetが実際にやっていること

cryptographyライブラリのFernetは、対称鍵暗号を安全に使うための仕様をひとまとめにしたものである。中身を分解すると、次の要素で構成されている。

  • AES(Advanced Encryption Standard)による暗号化: データ本体を鍵を使って暗号文に変換する
  • HMAC(Hash-based Message Authentication Code)による認証: 暗号文が後から改ざんされていないかを検証できる署名を付与する
  • タイムスタンプの埋め込み: トークンがいつ生成されたかを記録し、必要に応じて有効期限による失効判定に使える
  • URLセーフなBase64エンコード: 出力全体を印字可能な文字列に変換し、JSONやテキストファイルにそのまま保存できる形にする

ここで重要なのは、Fernetが「暗号化」だけでなく「認証」もセットで行う点である。単純に暗号化しただけのデータは、鍵を知らない第三者でも暗号文の一部を意図的に書き換えて壊すこと自体は可能な場合がある(内容は読めなくても改ざんはできてしまう)。FernetはHMACによる検証を組み込んでいるため、復号時に暗号文が生成時から一切変わっていないことも同時に確認できる。壊れた・改ざんされたトークンは復号処理自体が失敗する。

from cryptography.fernet import Fernet

key = Fernet.generate_key()          # 鍵の生成(32バイトの乱数をURLセーフBase64化したもの)
f = Fernet(key)

token = f.encrypt(b"my-secret-password")   # 暗号化 → トークン
plain = f.decrypt(token)                   # 復号 → 元のバイト列

鍵をどこに保存し、どう失わないようにするか

対称鍵暗号を使う上で最も本質的な課題は、暗号化アルゴリズムの選択そのものより「鍵をどう管理するか」にある。鍵さえ手元にあれば誰でも復号できてしまう一方、鍵を失えば暗号化されたデータは原理的に二度と読めなくなる。これはパスワード認証の「忘れたら再発行できる」とは根本的に異なる制約であり、鍵の永続化には相応の注意が必要になる。

このアプリでは、アプリの初回起動時に鍵を新規生成し、ユーザーのホームディレクトリ配下に非表示ファイルとして保存する。ファイルの権限は所有者のみ読み書き可能な範囲に絞り、次回起動時は同じ鍵を読み込んで使い続ける。さらに、万一そのファイルが何らかの理由で消えてしまった場合に備え、別の場所にも同じ鍵のコピーを保持し、起動時に両方の存在を確認して片方が欠けていればもう片方から復元するという相互バックアップの仕組みを持たせている。

# 要旨: プライマリ → バックアップの順に読み込みを試行し、
# どちらかが欠けていれば存在する方から復元する
key = load_from(PRIMARY) or load_from(BACKUP) or Fernet.generate_key()
sync_to_both_locations(key)

こうした「鍵の在り処を複数持ち、起動のたびに整合性を取る」設計は、暗号化そのものの実装よりも地味だが、実運用では暗号アルゴリズムの選定と同じくらい重要な部分である。鍵を1箇所にしか置かないと、そのファイルの誤削除やディスク障害がそのままデータ復元不能に直結してしまう。

「暗号化済みか」を値自体に持たせる設計

このアプリの実装でもう一つ興味深いのは、暗号化した値の先頭にENC:という接頭辞を付けて保存している点である。

def encrypt_value(value: str) -> str:
    if value.startswith("ENC:"):
        return value  # すでに暗号化されている場合はスキップ
    encrypted = fernet.encrypt(value.encode()).decode()
    return f"ENC:{encrypted}"

これは単なる飾りではなく、2つの実務的な問題を同時に解決している。1つ目は、既に暗号化済みの値をうっかりもう一度暗号化してしまう「二重暗号化」を防ぐこと。暗号化処理を何度呼び出しても安全(冪等)になる。2つ目は、過去に平文で保存されていた古いデータと、新たに暗号化されたデータが同じファイル内に混在していても、値を見ればどちらの状態かを判別できること。これにより、暗号化機能を後から追加した場合でも、既存データを一括変換する移行作業を待たずに、新規保存分から段階的に暗号化を効かせていくことができる。

「全部暗号化」ではなく「必要な項目だけ」

すべての設定値を無差別に暗号化するのではなく、辞書(dict)の中から指定したキーだけを暗号化するencrypt_dictのような関数を用意し、呼び出し側が「どのフィールドが機密情報か」を明示的に指定する設計になっている。

def encrypt_dict(data: dict, keys_to_encrypt: list) -> dict:
    result = data.copy()
    for k in keys_to_encrypt:
        if k in result and result[k]:
            result[k] = encrypt_value(str(result[k]))
    return result

# 呼び出し側: パスワードやAPIキーなど機密性の高いキーだけを指定
site = encrypt_dict(site, ["ssh_password", "api_key"])

サイト名やURLのような機密性のない項目まで暗号化してしまうと、設定ファイルを人間が目視で確認したりdiffで差分を追ったりする際の可読性が失われる。暗号化対象を許可リスト方式で明示的に絞ることで、「機密情報だけが暗号化され、それ以外は平文のまま追跡できる」というバランスを取っている。

復号に失敗しても例外を投げない設計

最後に、復号処理はエラー時に例外を送出せず、失敗した場合はそのままの値を返すようになっている。

def decrypt_value(value: str) -> str:
    try:
        return fernet.decrypt(value[4:].encode()).decode()
    except Exception:
        return value  # 復号失敗時は現状を返す(エラーにしない)

これは、他のマシンで生成された異なる鍵で暗号化されたデータを誤って読み込んでしまった場合や、手動編集でファイルが壊れてしまった場合に、アプリ全体がクラッシュするのを避けるための設計判断である。復号できない値がそのまま返る(=暗号化されたままの文字列が表示される)だけで、他の正常な設定項目の読み込みや起動処理自体は継続できる。「一部のデータが読めない」という劣化した状態と「アプリが起動すらできない」という状態のどちらが実運用上望ましいかを踏まえた、可用性寄りの判断といえる。

まとめ

設計要素 目的
Fernet(AES+HMAC+タイムスタンプ) 暗号化と同時に改ざん検知も行う
鍵の複数箇所保存+相互復元 鍵の消失=データ復元不能というリスクを減らす
ENC:接頭辞 二重暗号化の防止・新旧データ混在時の判別
許可リスト方式のencrypt_dict 機密情報だけを暗号化し可読性を両立
復号失敗時は例外を投げない 一部データの破損がアプリ全体のクラッシュに波及しない

対称鍵暗号そのものは枯れた技術だが、実際にアプリへ組み込む際には「鍵をどう保管し失わないようにするか」「暗号化済みかどうかをどう判別するか」「エラー時にどう振る舞うべきか」といった、アルゴリズムの外側にある設計判断の積み重ねが安全性と実用性の両方を左右する。