← ./articles-ja

Tauriデスクトップアプリのライセンス認証はCloudflare Workerでプロキシする

デスクトップアプリのバイナリに、決済サービスやライセンスサービスのAPIキーを安全に隠すことはできません。

TauriやElectron、ネイティブアプリにsecretを埋め込むなら、それはユーザーの手元に配布されます。難読化しても、Rustでコンパイルしても、信頼境界は変わりません。

ライセンス認証では、Cloudflare Workerのような小さなバックエンドでプロキシするほうが安全です。

問題: バイナリにAPIキーを入れない

危険な構成はこれです。

desktop app -> Lemon Squeezy API

この形では、アプリがprovider APIを直接呼ぶためのcredentialを持ちます。credentialがライセンスのactivateやvalidateに使えるなら、全ユーザーに配布すべきではありません。

安全な構成: appからWorkerへ送る

構成をこうします。

desktop app -> Cloudflare Worker -> license provider API

デスクトップアプリは、ユーザー入力とデバイス情報だけを送ります。

{
  "license_key": "XXXX-XXXX-XXXX-XXXX",
  "instance_name": "device-or-installation-id"
}

Workerがserver-sideでAPIキーを足し、provider APIを呼びます。アプリには必要最小限の結果だけ返します。

{
  "ok": true,
  "status": "active",
  "instance_id": "instance_123"
}

providerのraw responseをそのまま返さないほうが、UIもテストも安定します。

secretはWranglerで保存する

Cloudflare Workerではsecretを使います。

npx wrangler secret put LEMON_SQUEEZY_API_KEY

Worker側では env から読みます。

export interface Env {
  LEMON_SQUEEZY_API_KEY: string;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const body = await request.json();

    const providerResp = await fetch("https://api.lemonsqueezy.com/v1/licenses/activate", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${env.LEMON_SQUEEZY_API_KEY}`,
      },
      body: JSON.stringify({
        license_key: body.license_key,
        instance_name: body.instance_name,
      }),
    });

    const data = await providerResp.json();

    return Response.json({
      ok: Boolean(data.activated),
      status: data.license_key?.status ?? "unknown",
      instance_id: data.instance?.id ?? null,
    });
  },
};

ポイントは、デスクトップアプリがprovider API keyを一度も受け取らないことです。

API quotaを無駄にしない

Workerの入口で、明らかに不正な入力を弾きます。

function isPossibleLicenseKey(value: unknown): value is string {
  return typeof value === "string" && value.length > 0 && value.length <= 256;
}

失敗理由も分けます。

if (!isPossibleLicenseKey(body.license_key)) {
  return Response.json({ ok: false, reason: "invalid_format" }, { status: 400 });
}

false だけ返すと、無効キー、activation limit、ネットワーク障害、Worker障害の区別がつきません。

WebViewのOriginを意識する

TauriなどのWebViewは、通常のWebサイトと同じOriginにならない場合があります。cookieを使わず、browser credentialに依存しないなら、CORSは単純にできます。

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};

これはライセンス検証の代わりではありません。WebViewからWorkerへ到達できるようにするだけです。

出荷前チェック

  • production Workerにsecretが入っている
  • desktop repoやconfigにAPIキーがない
  • invalid inputでprovider APIを呼ばない
  • activation limitをUIに明確に出す
  • logにlicense key全文を出さない
  • Worker停止時にrecoverableなnetwork errorとして扱う

参考