目次
個人開発でClaude APIを使ったアプリを公開するとき、最初にぶつかるのが「APIキーどうする問題」です。
たべメモ(雑に書くとAIがカロリーを推定して、ウィジェットの残高が減るiOSアプリ)をApp Storeに出すとき、ここをApp Attest + Vercelのプロキシで解きました。実装して分かったことを書きます。
最初のバージョンは、設定画面でAnthropicのAPIキーを入力してもらう作りでした。開発者にはこれで十分です。実費は使う人が払うので、こちらの財布も痛まない。
ただ、これを一般公開のアプリでやると、初回起動で「Anthropicのコンソールでアカウントを作り、クレジットカードを登録し、キーを発行してコピーして、アプリに貼り付ける」を要求することになります。カロリーを記録したいだけの人が越えられる壁ではありません。
かといってキーをアプリに埋め込むのは論外で、iOSアプリのバイナリから文字列を抜くのは難しくありません。抜かれたキーは他人の課金になります。
結論として、キーはサーバに置き、実費は開発者が持つしかない。そうすると次の問題が出ます。「じゃあそのサーバのエンドポイント、誰でも叩けるよね?」
iOSアプリ ──(App Attestヘッダ)──> tabememo-api (Vercel) ──(APIキー)──> AnthropicキーはVercelの環境変数に置きます。アプリはキーを一切持ちません。代わりに「このリクエストは、App Storeから入った本物のたべメモアプリから来ている」ことをApp Attestで証明します。
App AttestはAppleのDeviceCheckフレームワークの機能で、端末のセキュアエンクレーブに鍵を作り、「この鍵は正規のあなたのアプリが持っている」ことをAppleが保証してくれます。改造アプリやcurlからは作れません。
初回だけ、鍵を作ってサーバに登録します。
let keyId = try await service.generateKey()
let challenge = try await fetchChallenge() // GET /api/challenge
let hash = Data(SHA256.hash(data: Data(challenge.utf8)))
let attestation = try await service.attestKey(keyId, clientDataHash: hash)
try await register(keyId: keyId, attestation: attestation, challenge: challenge) // POST /api/attest以降は、推定リクエストのたびにassertion(署名)を作ってヘッダに載せます。
let challenge = try await fetchChallenge()
let hash = Data(SHA256.hash(data: Data((challenge + body).utf8)))
let assertion = try await service.generateAssertion(keyId, clientDataHash: hash)
return [
"x-attest-key-id": keyId,
"x-attest-assertion": assertion.base64EncodedString(),
"x-attest-challenge": challenge,
]ポイントは challenge + body をハッシュしていることです。チャレンジだけを署名すると、署名を横取りして中身だけ別のリクエストに差し替えられます。入力テキストごと署名に含めることで、その署名はその1リクエスト専用になります。
keyIDはApp Groupに保存する。たべメモはSiri(App Intent)からも記録できます。keyIDを UserDefaults(suiteName:) でApp Groupに置くと、アプリ本体とSiri経路が同じ端末として扱われ、無駄な再登録が走りません。
App Attestはアプリプロセスでしか使えない。共有フレームワーク側に置こうとして失敗しました。APIクライアントはフレームワークにあるので、AttestationProviding というプロトコルだけをフレームワークに置き、実体はAppターゲットに置いて注入しています。
シミュレータはApp Attest非対応。DCAppAttestService.isSupported がfalseを返すので、開発中は共有シークレットのバイパスヘッダに切り替えています(本番のサーバではその環境変数を未設定にしておく)。ここを分けておかないと、開発が全部実機になって死にます。
BYOキーは残した。Keychainにキーがあればそちらを使ってAnthropicを直叩きします。設定画面の「上級者向け」に畳んであるので普通の人の目には入りませんが、自分で払いたい人には道が残ります。
エラー文言はキーの有無で出し分ける。401が返ったとき、BYOキーの人には「APIキーが正しくないようです」、プロキシ経由(=キーを持っていない大多数)には「うまく推定できませんでした」と出します。キーを設定した覚えのない人に「APIキーを確認してください」と言うのは、それだけで問い合わせが増えます。
case .httpError(let status, _, let byoKey):
switch status {
case 401, 403:
return byoKey ? "APIキーが正しくないようです。..." : "うまく推定できませんでした。..."
case 429:
return "アクセスが集中しています。少し待って再試行してください。"サーバ側でレート制限をかけている以上、429は必ず起きる前提でユーザー向けの日本語に落としておく必要があります。
通信が失敗しても記録は捨てない。推定できなかった入力は「未推定」の状態で保存し、後からタップで再推定できます。プロキシを挟むと、自分のサーバが落ちた瞬間にアプリが使えなくなるので、ここは必須でした。
正直に書いておくと、サーバ側でkeyIdを失うと復旧できません。端末はApp Groupに保存したkeyIdを使い続けるので、サーバのデータが飛ぶと401が永久に返り続けます。プロキシが401を返したら保存済みkeyIdを捨てて一度だけ再登録する、というリトライが要ります。プロトコルの拡張が必要なので後回しにしていますが、実運用でDBを触るなら先に入れるべきところです。
実費は開発者持ちになるので、モデルは claude-haiku-4-5、max_tokens は1000に絞っています。BYO直叩き側ではシステムプロンプトに cache_control: ephemeral を付けてプロンプトキャッシュを効かせています。回収はAdMobのバナー・全画面広告と、広告非表示の買い切り(¥300)です。
「キーを入力させない」は、ユーザーから見れば起動して3秒で使える、ただそれだけの体験です。そのために裏側でここまでやるのはやりすぎ感もありますが、初回起動でキー入力を求めた時点で大半の人は消えるので、個人開発でAIアプリを出すなら避けて通れないところだと思います。
たべメモはApp Storeで公開中です。同じ構成を検討している方の参考になれば。
コメントはまだありません