AI

PrefectのSecretブロックは暗号鍵の管理で注意していること

  • Prefect
  • Security
  • Python

Prefect の Secret ブロックは、PREFECT_SERVER_ENCRYPTION_KEY を渡さないと、暗号化に使う Fernet 鍵を暗号文と同じ SQLite ファイルの configuration テーブルに平文で書いてしまいます。ドキュメントには「保存時に暗号化される」としか書かれておらず、鍵の置き場は出てきません。社内の自動化基盤で AI エージェント用の資格情報をどこに置くかを決めるときに、ソースを読んで実機で確かめた記録です。

「暗号化される」の中身をソースで読む

Prefect のドキュメントは「Secret values are encrypted at rest when stored in your Prefect backend.」と書いています。鍵をどう管理するかは書かれていません。セキュリティ設定のページも Basic 認証、CSRF、CORS、リバースプロキシまでで、暗号鍵については触れられていませんでした。

インストール済みの Prefect 3.8.4 では、Secret.save() の値はサーバー側の server/models/block_documents.pyencrypt_data に渡され、server/utilities/encryption.py で Fernet 暗号化されます。鍵を決めているのは同じファイルの _get_fernet_encryption です。

environment_key = os.getenv(
    "PREFECT_SERVER_ENCRYPTION_KEY",
    os.getenv("ORION_ENCRYPTION_KEY"),
)
if environment_key:
    return Fernet(environment_key.encode())

configured_key = await configuration.read_configuration(session, "ENCRYPTION_KEY")

if configured_key is None:
    encryption_key = Fernet.generate_key()
    configured_key = schemas.core.Configuration(
        key="ENCRYPTION_KEY", value={"fernet_key": encryption_key.decode()}
    )
    await configuration.write_configuration(session, configured_key)

環境変数があればそれを使い、なければ鍵を生成して configuration テーブルに書きます。書き出し先は暗号文が入る block_document と同じデータベースです。

SQLite の中身を見る

読んだだけでは信用しないことにしているので、使い捨ての PREFECT_HOME を切って ephemeral server で 1 件保存し、SQLite を直接見てみます。

from prefect.blocks.system import Secret
Secret(value="dummy").save("probe", overwrite=True)

環境変数を渡さずに実行した結果は、次のようになりました(鍵は検証用の使い捨てで、省略して載せています)。

$ sqlite3 home/prefect.db "select name, substr(data,1,12), length(data) from block_document;"
probe|"gAAAAABqnOe|122

$ sqlite3 home/prefect.db "select key, value from configuration;"
ENCRYPTION_KEY|{"fernet_key": "1b3yegm0…7c="}

block_document.datagAAAAA で始まる Fernet の暗号文になっています。その隣の configuration テーブルには、復号に必要な鍵がそのまま入っています。SQLite の DB ファイルを読める相手には、復号に必要な鍵ごと見えている状態です。

PREFECT_SERVER_ENCRYPTION_KEY に鍵を渡して同じことをすると、block_document.data は同じ形の暗号文のまま、configuration テーブルは 0 行でした。鍵はデータベースに書かれません。

環境変数を渡さない場合は prefect.db の中に block_document の暗号文と configuration の平文鍵が並ぶ。環境変数で鍵を渡す場合は prefect.db に暗号文だけが残り、鍵はプロセスの環境にある

鍵は launchd の環境変数で渡す

決めたのは 2 つです。

1 つ目は、PREFECT_SERVER_ENCRYPTION_KEY を必ず明示することです。 Prefect サーバーを起動する launchd の LaunchAgent に EnvironmentVariables として鍵と PREFECT_API_URL を書けば非対話の子プロセスまで届くことを、使い捨ての LaunchAgent で実際に読み出して確かめました。忘れると何のエラーも出ずに「鍵と暗号文が同じファイル」に戻るので、プロビジョニング手順に組み込んでいます。

2 つ目は、Secret.load() を呼ぶのはフローのコードだけにして、エージェントに登録するツールからは決して呼ばないことです。 エージェントから呼べるものは、プロンプト次第で外に出せるものになるからです。Prefect OSS の認証は、server/api/server.pyPREFECT_SERVER_API_AUTH_STRING の 1 本の文字列を hmac.compare_digest で比べるだけで、主体やリソースで分岐する箇所がありません。ブロックの読み取り API も include_secrets をリクエストボディで受け取るだけです。実装を読む限り、ブロック単位で読める相手を制限する仕組みは OSS 側にはないので、境界はコードの書き方で守るしかありません。

AI エージェントはツールを呼び、ツールはフローを起動し、フローが Secret.load() で Prefect の API から値を取り出す。境界はツールとフローの間にあり、エージェントとツールの層では Secret.load() を呼ばない。Prefect OSS の API は 1 本の認証文字列を比べるだけで主体やブロック単位で分けられないので、境界を守るのはコードの書き方だけになる

登録は値を標準入力から受け取る短いスクリプトにしました。prefect block create は値を受け取れず UI のリンクを出すだけで、CLI 引数で渡すとシェルの履歴に残るからです。

import sys
from prefect.blocks.system import Secret

Secret(value=sys.stdin.readline().rstrip("\n")).save(sys.argv[1], overwrite=True)
read -rs TOKEN && printf '%s\n' "$TOKEN" | uv run python register_secret.py x-access-token && unset TOKEN

ブロックは 1 つの資格情報につき 1 つです。Secret.get() は保存した文字列に json.loads をかけるので、複数の値を JSON っぽい形で詰めると str のつもりが別の型で返ってきます。

この方式で守れる範囲

環境変数の鍵は plist に平文で書かれるので、守れているのは「データベースのファイルだけを読まれた場合」までです。plist をラッパースクリプトからの注入に変えるかどうかは、サーバーの常駐方法と一緒に決めます。 またこの方式では、マシンに侵入されている場合の保護はかなり限定的で、鍵が同じ DB にあるよりは多少マシという程度です。

外部サービスへ投稿できる資格情報を持つ役が 2 つに増えたら、同じ basic auth の内側に別の役の秘密が並ぶことになり、コードの書き方だけで分けている境界が、資格情報の側では成り立たなくなります。そのときは 1Password の Service Account など、別の仕組みを検討することになります。