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.py で encrypt_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.data は gAAAAA で始まる Fernet の暗号文になっています。その隣の configuration テーブルには、復号に必要な鍵がそのまま入っています。SQLite の DB ファイルを読める相手には、復号に必要な鍵ごと見えている状態です。
PREFECT_SERVER_ENCRYPTION_KEY に鍵を渡して同じことをすると、block_document.data は同じ形の暗号文のまま、configuration テーブルは 0 行でした。鍵はデータベースに書かれません。
鍵は launchd の環境変数で渡す
決めたのは 2 つです。
1 つ目は、PREFECT_SERVER_ENCRYPTION_KEY を必ず明示することです。
Prefect サーバーを起動する launchd の LaunchAgent に EnvironmentVariables として鍵と PREFECT_API_URL を書けば非対話の子プロセスまで届くことを、使い捨ての LaunchAgent で実際に読み出して確かめました。忘れると何のエラーも出ずに「鍵と暗号文が同じファイル」に戻るので、プロビジョニング手順に組み込んでいます。
2 つ目は、Secret.load() を呼ぶのはフローのコードだけにして、エージェントに登録するツールからは決して呼ばないことです。
エージェントから呼べるものは、プロンプト次第で外に出せるものになるからです。Prefect OSS の認証は、server/api/server.py で PREFECT_SERVER_API_AUTH_STRING の 1 本の文字列を hmac.compare_digest で比べるだけで、主体やリソースで分岐する箇所がありません。ブロックの読み取り API も include_secrets をリクエストボディで受け取るだけです。実装を読む限り、ブロック単位で読める相手を制限する仕組みは OSS 側にはないので、境界はコードの書き方で守るしかありません。
登録は値を標準入力から受け取る短いスクリプトにしました。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 など、別の仕組みを検討することになります。
