署名付きURLで鍵を隠す
エージェントに生成物をファイルとして持ち帰らせたい。取得口にBearerトークンを必須にすると、エージェントのシェルにそのトークンが要る。シェルで動くものはプロセスの環境変数を読めるので、親トークンをシェルに置いた時点で、口を絞った意味が薄れる。
ここに置くのは、親トークンを渡さず、その1本のファイルだけを一時的に取れるURLを発行する型だ。経緯は開発日記のAIに動画を作らせる 開けた口は3つだけにある。
型
ツールの返り値に、期限つきの署名付きパスを載せる。エージェントはそれをcurlに渡すだけで、認証ヘッダーを持たない。
署名の実装は次の1関数で足りる。先頭のfile:は用途の接頭辞で、将来ほかの署名を足しても取り違えないためのもの。
import { createHmac } from 'node:crypto';
export function signDownload(token: string, jobId: string, index: number, expSec: number): string {
return createHmac('sha256', token)
.update(`file:${jobId}:${index}:${expSec}`)
.digest('hex');
}
有効期限は15分。切れたら、ツールをもう一度呼ぶ。呼ぶたびに新しく発行するので、エージェント側に「更新」の処理は要らない。
検証の順番
検証は、署名の比較より前に形式を縛る。順番は次のとおり。
Node.jsのtimingSafeEqualに長さの違う2つを渡すと、比較の前に例外になる。実際に3バイトと4バイトで試した。
RangeError ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH
短い署名を送られたときに500を返してしまわないよう、sigは「16進64桁」だけを通してから比較する。
実測 6通りの悪用
2026-09-26、video-gen/mcpのテスト51件が0.37秒で通った(GPU不要。動画生成は偽の生成器に差し替えてある。この後、Origin検証を足して53件になった)。うち、署名付きURLを悪用する6通りは、実際にHTTPで叩いていずれも401だった。
- 署名の末尾1文字を書き換える
- 署名はそのまま、期限だけ1時間延ばす
- 期限切れ(署名自体は正しい)
- 同じジョブの別のショット番号へ付け替える
- 別のジョブのURLへ付け替える
- 署名を付けない
期限の境目も確かめた。**期限の秒ちょうどはまだ有効で、その1秒後から無効。**署名付きURLで/mcpに入ろうとする試みも401だった。署名で通るのは/filesの該当ファイルだけ、と分けてあるためだ。
残る弱点
- **URLを持っている間は誰でも、その1本を取れる。**期限は15分。ツールの返り値はエージェントの会話にも入るので、URLごと文脈に残る
- **署名の鍵は親トークンと同じ値。**親トークンを変えれば、発行済みのURLはすべて切れる
- **Bearerでも
/filesは取れる。**手元の確認用に残してある。エージェントのシェルにBearerが無いことが前提で、これは設定で守る部分 - MCPの仕様は、Streamable HTTPのサーバーが
Originヘッダーを検証することを求めている(2025-06-18版、DNS再バインディング対策)。このサーバーは最初、Originを見ていなかった(2026-09-26に直した)。Origin付きの要求は認証より前に403にし、許可する送信元だけ環境変数で足す。署名付きURLの/filesには適用しない
関連
- 開発日記: AIに動画を作らせる 開けた口は3つだけ — 口を3つに絞った理由と、この型に切り替えた経緯
- 鍵を渡さないpush設計 — 同じ考え方をGitHubの鍵で適用した例
- ガードが素通りする書き方 — 検査が効いていないことに気づけない書き方