本文へ移動
BenriWorks Lab

開発記事公開日

買い切りアプリのライセンスをオフラインで検証する

木の机に置かれた真鍮の鍵と、封蝋で閉じられた封筒の接写

学習ツールwavelaneで、Ed25519署名トークンによりサーバー通信なしでライセンスを検証し、個人情報も持たない設計にした実装を紹介します

買い切りのアプリを作るとき、最後まで残る宿題がライセンス認証です。素朴に組むなら、起動のたびにサーバーへキーを送って有効かどうかを確かめる形になるでしょう。ただ、この形は認証サーバーの稼働がアプリの稼働条件になることを意味します。サーバーが落ちれば、お金を払った人のアプリまで止まります。売り切ったあとも、認証のためだけにサーバーを維持し続けることになります。この義務は、個人開発には重すぎます。開発中の学習ツールでは逆の配置を選びました。サーバーの仕事は署名済みトークンを一度発行するところまでとし、検証はクライアントの手元で完結させる設計です。この記事では、Ed25519署名によるオフライン検証と、サーバーに個人情報を残さない構成を、実装(公開準備中)のコードで紹介します。

認証のたびにサーバーへ問い合わせる設計

今回のアプリは無線従事者試験の受験者向けの学習ツールで、Free機能とPro機能を買い切りで分けます。要件には「ライセンス検証後は完全オフライン動作」「学習データや入力値の外部送信はゼロ、通信はライセンス検証の1回のみ」を掲げました。学習データすら送らないアプリで、認証だけが毎回の通信を要求するわけにはいきません。

毎回問い合わせる設計の弱点は、通信の回数そのものより、依存の向きにあります。月額課金なら、サーバーの維持は続いていく売上と釣り合います。買い切りでは売上が先に終わり、維持の義務だけが残ります。しかも購入済みの利用者がいるかぎり、サーバーを畳む選択肢はありません。

では、認証のたびに確かめている中身は何でしょうか。「サーバーがいまこのキーを覚えているか」ではなく、「このキーは正規に発行されたものか」のはずです。発行の正しさなら、署名で一度だけ証明できます。サーバーは証明書を発行する係に退き、以後の検証は受け取った側が手元で済ませれば足ります。この分担を、Ed25519の鍵ペアで作りました。

一度だけ発行して手元で検証する署名トークン

購入から認証までは4手です。Stripe Payment Linksで決済し(カード情報はアプリを通りません)、StripeのWebhookを受けたサーバーがライセンスキーを生成してメールで届け、利用者がアプリにキーを入力すると、検証APIがEd25519秘密鍵で署名したライセンストークンを返します。クライアントはトークンをlocalStorageに保存し、ビルドに埋め込んだ公開鍵で検証してPro機能を解錠します。以後、APIは一度も呼びません。クライアントが呼ぶAPIは最初からこの1本だけで、Webhookの通信はStripeとサーバーの間で完結します。

メールで届くキーはWVLNで始まる16桁で、0とO、1とIとLのような紛らわしい文字をあらかじめ文字集合から除いてあります。小文字で打っても、空白やハイフンの位置が崩れていても、サーバー側で正規化してから照合します。キーを手で打ち直す利用者がいる前提の設計です。

トークンの中身は、バージョン、プラン、キーのSHA-256ハッシュ、発行時刻の4項目だけです。形式は base64url(payloadのJSON) + "." + base64url(署名) で、JWTからヘッダとアルゴリズム交渉を取り除いたような固定構造にしています。署名と検証には@noble/ed25519を使い(v3系は同期APIにsha512の注入が必要です)、base64urlはパディングなしの自前実装にしました。同じモジュールがサーバー(署名)とブラウザ(検証)の両方で動く必要があり、NodeのBufferに寄りかかれないからです。

クライアント側の検証は、失敗の理由を区別せず、どれもnullを返します。署名不一致でも、形式不正でも、スキーマ不一致でも、帰結は「Free扱い」の一つしかないからです。

export function verifyLicenseToken(
  token: string,
  publicKeyHex: string,
): LicenseTokenPayload | null {
  try {
    const dot = token.indexOf(".");
    if (dot <= 0) return null;
    const msg = base64UrlToBytes(token.slice(0, dot));
    const sig = base64UrlToBytes(token.slice(dot + 1));
    if (msg === null || sig === null || sig.length !== 64) return null;
    if (!ed.verify(sig, msg, ed.etc.hexToBytes(publicKeyHex))) return null;
    // 以下、payload のスキーマ(バージョンとプランと型)を検査して通過したものだけ返す
  } catch {
    return null;
  }
}

改竄への耐性は単体テストで固定しています。payloadを1文字変えたトークンも、別の秘密鍵で署名したトークンも、検証でnullになります。

では、検証をクライアントに置いてしまって、Pro機能はコピーし放題にならないのでしょうか。署名が防げるのは偽造までです。公開鍵から秘密鍵を割り出すことは現実的にできないため、正規のサーバー以外は検証を通るトークンを作れません。一方で、検証コードごと書き換えるようなクライアント改造は、ローカル検証である以上防げません。ここは割り切っていて、仕様でもデバイス制限を設けず、個人利用前提の緩い運用でサポートコストを下げる方針にしています。

購入キーとメールのハッシュしか持たない記録

サーバーに何も置かないわけにはいきません。どのキーが正規に発行されたものかは、トークンと引き換える瞬間に、サーバーが知っている必要があります。そこで、預かるものを最小にしました。KVに置くのは、キーのSHA-256ハッシュを添字にしたこのレコードだけです。

// KV スキーマ: license:{sha256(key)} = { emailHash, plan: "pro", createdAt, revoked? }
interface LicenseRecord {
  emailHash: string;
  plan: "pro";
  createdAt: string;
  revoked?: boolean;
}

生のライセンスキーも生のメールアドレスも、サーバーのどこにも保存しません。検証APIは入力されたキーを正規化してハッシュし、レコードを引き当てるだけなので、照合に生の値は要らないのです。トークンに封入するのも同じハッシュで、生キーはトークンにも含めません。なお、ハッシュにすれば安全と言い切るつもりはありません。メールアドレスのような推測しやすい値は、ハッシュでも総当たりで照合される余地が残ります。それでも、生の値を並べた名簿を持つ場合とは、預かっている情報の重さが違います。

レコードには失効フラグがあります。失効済みのキーは未登録のキーと同じ404で断り、キーの存在の有無を応答から漏らしません。ただし、このフラグが効くのはトークンの新規発行までです。すでに発行されて手元にあるトークンは、オフラインで検証が通り続けます。あとから取り消せないことは、オフライン検証を選んだ時点で引き受けた代償です。

署名鍵が未設定の期間を503で閉じる判断

この検証APIには少し変わった事情があります。実装と単体テストは済んでいますが、決済との接続はこれからで、本番の署名鍵もKVの接続情報も設定されていない期間が続きます。その状態でエンドポイントが呼ばれると、KVクライアントの初期化が例外を投げ、原因の読めない500が返ってしまいます。そこでルートの先頭で環境変数を検査し、揃っていなければ503で断ることにしました。

const signingKey = process.env.LICENSE_SIGNING_KEY;
const upstashUrl = process.env.UPSTASH_REDIS_REST_URL;
const upstashToken = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!signingKey || !upstashUrl || !upstashToken) {
  // 未設定なら fail-closed(Redis.fromEnv() の throw による 500 を防ぐ)
  return NextResponse.json({ error: "server_not_configured" }, { status: 503 });
}

「壊れて止まる」500ではなく、「閉じていると宣言して止まる」503に倒す判断で、実装のコメントではfail-closedと呼んでいます。画面側はこの応答を「サーバー側の設定が未完了です(準備中)」と表示します。未設定は事故ではなく想定内の状態なので、事故の500と区別できる応答にしておけば、公開後の監視でも紛れません。単体テストでは、署名鍵だけが欠けた場合とKVの接続情報だけが欠けた場合の、どちらも503になることを確かめています。

防御はもう一つ、IPごとに10分間で10回までの素朴なレートリミットを、KVのカウンタで入れています。16桁のキー空間に対して総当たりが成立する見込みはまずありませんが、無意味な連打をKVの照合の手前で弾く最低限の備えです。11回目の試行が429で断られることも、テストに含めています。

サーバーの寿命とアプリの寿命の切り離し

冒頭の単一障害点に戻ります。この構成でサーバーが働くのは、購入直後のキー発行と、キー入力時の一度の署名だけです。認証を済ませた利用者の手元では、検証は埋め込みの公開鍵と数十行のコードで完結し、サーバーが止まっていても、回線のない場所でも、Pro機能は動き続けます(ブラウザのデータ削除などでトークンが消えたときだけ、再認証の一度の通信が要ります)。買い切りという売り方に依存の向きを揃えた結果、認証サーバーの障害や撤退が、支払ってくれた人のアプリを道連れにしない構成になりました。

この仕組みを組み込んだ「wavelane」は、一陸技と二陸技の受験者向けに電波伝搬を可視化する学習ツールで、現在公開準備中です。ライセンス認証も決済をつなぐ手前の段階にあり、この記事のコードは実装と単体テストまでを終えた時点のものです。決済をつないで運用が始まれば、再発行の頻度やレートリミットの妥当性など、机上では決めきれなかった数字が見えてくるはずです。その答え合わせは、公開後にあらためて書きます。

関連アプリ

こんな記事も読まれています