mcpField
S
seeker-genesis-token

seeker-genesis-token

github.com/solana-mobileセキュリティ812.6k 回インストール1 週間前に更新
リポジトリ

seeker-genesis-token について

Seeker Genesis Token verificationは、ユーザーがSolanaウォレットを管理し、Seeker Genesis Tokenを保有していることをサーバー側で検証するスキルです。ワンタイムnonceを使うSIWS検証と、報酬付与などでデバイスごとの請求を制限する方法を説明します。

主な機能

  1. 01サーバー発行の使い捨てSIWS nonce
  2. 02ウォレット署名検証の手順
  3. 03メインネットでのSGT保有確認
  4. 04mintアドレスによるデバイス単位の請求
  5. 05並行処理と再試行への考慮

活用シーン

  1. 01Seeker保有者への報酬制限
  2. 02デバイスごとに請求を1回に制限
  3. 03サーバー側でのウォレット所有確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/solana-mobile/solana-mobile-skills --skill seeker-genesis-token

インストール後、Claude Code で/seeker-genesis-tokenを入力して読み込まれたか確認します。

詳細

Seeker Genesis Token の検証

Seeker Genesis Token(SGT)は、Seeker デバイスごとに一度だけミントされる Token-2022 NFT です。1つ保有していることは、Seeker の所有を証明するため、報酬の利用制限やデバイスごとに1回だけ請求できる仕組みに役立ちます。

検証には2つの手順があり、両方が必要です。

  1. ユーザーがウォレットを操作できることを証明する — Sign-in-with-Solana(SIWS)
  2. そのウォレットが SGT を保有していることを証明する — ウォレットの Token-2022 ミントを確認する

2つ目だけを行うと、Seeker の実際の所有者の公開アドレスを誰でも送信でき、検証を通過できてしまいます。 1つ目だけではウォレットを操作できることは証明できますが、デバイスについては何も証明できません。

サーバー上で実行する必要があります

権利の有無をクライアント側で決して判断しないでください。 クライアントは改変できるため、クライアント側の hasSGT ブール値には何の価値もありません。クライアントの役割は署名を集めることです。サーバーがそれを検証し、結果を管理します。

必要なもの:

  • Solana mainnet RPC 呼び出しを実行できる、自分で管理するバックエンド
  • nonce と請求記録を保存するストレージ
  • mainnet RPC エンドポイント。この確認ではウォレットが保有するすべての Token-2022 アカウントを読み取るため、有料プロバイダーが役立ちます。このキーはサーバー側だけに保管し、EXPO_PUBLIC_* 変数には決して設定しないでください

前提条件

ウォレット接続が動作していること。アプリにウォレット接続がない場合は、まず solana-mobile-wallet スキルを使用してください。このスキルでは useMobileWallet() が利用可能であることを前提としています。

テストには実物の Seeker デバイスが必要です。エミュレーターでは SGT を保有できません。開発時にサーバー側の許可リストを使うなど、実機がなくても実行できるコードパスを用意してください。

Step 1: サーバーからペイロードを発行する

サーバーは nonce だけでなく、SIWS ペイロード全体を発行し、短い TTL を設定して nonce にひも付けて保存します。クライアントが指定するフィールドはすべて攻撃者が選べるため、署名検証にそのまま渡される各フィールドを発行時に固定してください。

// POST /api/siws/nonce
const issuedAt = new Date()
const nonce = crypto.randomBytes(16).toString('hex')

const payload = {
  chainId: 'solana:mainnet',
  domain: 'yourdapp.com',
  expirationTime: new Date(issuedAt.getTime() + 300_000).toISOString(),
  issuedAt: issuedAt.toISOString(),
  nonce,
  statement: 'Sign in to verify Seeker ownership',
  uri: 'https://yourdapp.com',
  version: '1',
}

await store.put(nonce, payload, { ttlSeconds: 300 })
return payload

nonce はサーバーが生成し、一度だけ使用でき、有効期間が短い必要があります。クライアント生成の nonce や再利用可能な nonce では署名を再利用できてしまい、検証の目的が損なわれます。

address は、サーバーが設定できない唯一のフィールドです。クライアントが Step 2 で追加します。

chainId はウォレットの chain から取得せず、意図的に solana:mainnet に固定します。SGT は mainnet にしか存在しないため、devnet を対象とした署名ではデバイスについて何も証明できません。サーバー側で発行することで、単なるコメントではなく保証になります。

Step 2: クライアントでサインインする

ウォレットフックの signIn を使うと、1回のプロンプトで承認と所有証明を行えます。発行されたペイロードを展開し、アドレスを追加します。

import { useMobileWallet } from '@wallet-ui/react-native-kit'

const { signIn } = useMobileWallet()

const issued = await fetch('https://yourdapp.com/api/siws/nonce', { method: 'POST' }).then(
  (response) => response.json(),
)

const address = account.address.toString()
const output = await signIn({ ...issued, address })

await fetch('https://yourdapp.com/api/siws/verify', {
  body: JSON.stringify({
    address,
    nonce: issued.nonce,
    signature: Array.from(output.signature),
    signedMessage: Array.from(output.signedMessage),
  }),
  headers: { 'Content-Type': 'application/json' },
  method: 'POST',
})

この4つのフィールドだけを正確に送信してください。output.account は送信しないでください。また、サーバーはクライアントからアカウントオブジェクトや公開鍵を決して受け入れてはいけません。署名の検証対象となる鍵は、サーバーが処理しようとしているアドレスから導出する必要があります。その理由は Step 3 で説明します。ここでの nonce は検索キーにすぎず、それにひも付けるのは署名検証です。

これは、短縮形の signIn ではなく、必要なフィールドがすべて指定されたペイロードです。domain、nonce、version によって署名が再利用できなくなり、アプリにひも付けられます。これを実現するには、サーバーがそれらを発行している必要があります。solana-mobile-wallet スキルでは、両方の形式と、それぞれを使う場面について説明しています。

ステップ 3:サーバーで署名を検証する

npm install @solana/kit @solana/wallet-standard-util
import { getBase58Encoder } from '@solana/kit'
import { verifySignIn } from '@solana/wallet-standard-util'

function verifySiws(issued, { address, signature, signedMessage }) {
  // Reject malformed input here, not inside the ed25519 verify.
  if (!Array.isArray(signature) || signature.length !== 64) {
    throw new Error('Malformed signature.')
  }
  if (!Array.isArray(signedMessage)) throw new Error('Malformed signed message.')

  // Derive the verifying key from the address, never from the request body.
  const publicKey = getBase58Encoder().encode(address)
  if (publicKey.length !== 32) throw new Error('Malformed address.')

  return verifySignIn(
    { ...issued, address },
    {
      account: { address, chains: [], features: [], publicKey },
      signature: new Uint8Array(signature),
      signedMessage: new Uint8Array(signedMessage),
    },
  )
}

3 つの形式チェックを最初に行うのは、これらの値がすべて攻撃者によって選ばれるためです。これらのチェックがないと、不正なケースが new Uint8Array() に到達し、その後の ed25519 検証で一貫しない結果になります。短い署名ではライブラリのバイト長に関するエラーが発生する一方、配列ではない signedMessage は空の配列に変換され、単純に false が返されます。形式をチェックして拒否すれば、どちらにも明確な応答を返せます。また、自分で制御できない内部実装にも依存しません。

verifySignIn は、渡したフィールドと署名済みテキスト内のフィールドを比較し、account.publicKey で署名を検証します。キーとアドレスが一致しているかはチェックしません。 そのキーをリクエスト本文から取得すると、使い捨てのキーペアで任意のアドレスを記載したメッセージに署名できてしまいます。署名自体は本物ですが、そのアドレスの所有者によるものではありません。

issued、domain、chainId はサーバーが発行するため、構造上、値が固定されています。クライアント側の値がチェックに使われることはないため、別途ドメインを比較する必要はありません。念のためアサーションを行う場合は、リクエストの値ではなく、保存済みの値である issued.domain に対して行ってください。

ステップ 4:ウォレットに SGT があるか確認する

@solana-mobile/skr-genesis-token を使用してください。このチェックを独自に実装しないでください。このパッケージは Solana Mobile によって保守されており、コピーしたコードで見落としがちな細部も正しく処理します。

npm install @solana-mobile/skr-genesis-token @solana/kit
import { getSeekerGenesisTokens } from '@solana-mobile/skr-genesis-token'
import { address, createSolanaRpc } from '@solana/kit'

// SGTs exist only on mainnet, whatever cluster the rest of the app targets.
const rpc = createSolanaRpc(process.env.SOLANA_MAINNET_RPC_URL!)

async function getWalletSGTMints(walletAddress: string) {
  const tokens = await getSeekerGenesisTokens(rpc, address(walletAddress))
  return tokens.map((token) => token.mint)
}

ブール値だけでなく、ミントアドレスを返してください。ミントはデバイスを識別するため、以下の Sybil 攻撃対策チェックで記録する対象です。最初のミントだけでなく、すべてのミントを保持してください。1 つのウォレットに複数のデバイスの SGT が入っている場合があり、そのすべてが申請する権利を持ちます。単純な真偽値を返す hasSeekerGenesisToken と、単一のミントを検証する isSeekerGenesisToken もあります。

パッケージが処理するため、重複して実装する必要がない内容は次のとおりです。

  • ミントが SGT であるためには、その TokenGroupMember グループと MetadataPointer の両方が SGT グループミントである必要があります。 名前やオフチェーンメタデータではなく、ミントのバイト列からこれらの拡張をデコードします。
  • 残高がゼロのトークンアカウントはスキップされます。 SGT を移転しても、以前のアカウントは残高 0 のまま開いた状態で残ります。それを数えると、過去のすべての保有者が永久に検証されてしまいます。
  • 凍結されたアカウントも拒否されません。 正当に保有されている SGT は凍結されたアカウントに入っています。その上に凍結チェックを追加しないでください。
  • RPC エラーが発生すると例外がスローされます。 そのまま処理してください。API 境界で 503 に変換し、空のリストには決して変換しないでください。そうしないと、障害時に「Seeker を所有していない」と判定されます。

ウォレットの Token-2022 アカウントは、ページネーションを使わず、標準の RPC メソッドのみを使って、1 回の getTokenAccountsByOwner リクエストで読み取ります。そのため、どの mainnet エンドポイントでも動作します。

ステップ 5:チェックを正しく組み合わせる

ここに微妙なバグがあります。verifySignIn はアカウントキーと署名済みメッセージ内のアドレスを関連付けないため、サーバー側でその関連付けを行う必要があります。処理対象のアドレスからキーを導出し、同じアドレスを対象に SGT を照会してください。

async function verifySeekerUser({ address, nonce, signature, signedMessage }) {
  // 1. Consume the nonce atomically. Read-then-mark leaves a window where two concurrent
  //    requests both see it unused, and one signature is accepted twice.
  //    Redis: GETDEL. SQL: DELETE FROM nonces WHERE nonce = $1 RETURNING payload.
  const issued = await store.consume(nonce)
  if (!issued) throw new Error('Invalid, reused, or expired nonce.')

  // 2. The signature must be valid for the payload we issued and for this address.
  if (!verifySiws(issued, { address, signature, signedMessage })) {
    throw new Error('Invalid signature.')
  }

  // 3. Check the SGT against the address the signature was just verified against.
  const mintAddresses = await getWalletSGTMints(address)

  return { address, mintAddresses }
}

別のアドレスに対して SGT をチェックすると、呼び出し側が実際の Seeker 所有者のアドレスと自分の署名を送信し、アクセスを許可されてしまいます。

Sybil 対策:デバイスごとに1回の請求

SGT はデバイスごとに発行されるため、mint address がデバイスの識別子になります。ウォレットアドレスではなく、こちらを保存してください。ウォレットは後から別の SGT を保有する可能性があり、デバイスの SGT がウォレット間で移動することもあります。

1回だけ請求できるようにするのは、アプリケーションコードではなく UNIQUE 制約です。

CREATE TABLE claims (
  claimed_at timestamptz NOT NULL DEFAULT now(),
  mint_address text NOT NULL UNIQUE,
  paid_at timestamptz
);
const { address, mintAddresses } = await verifySeekerUser(request.body)
if (mintAddresses.length === 0) throw new Error('No Seeker Genesis Token found.')

// Insert first and let the constraint decide. Asking whether a row exists and then
// inserting is a double-claim bug: two concurrent requests both find nothing.
// Try each held SGT in turn, so a wallet whose first device has claimed can claim for the next.
let mintAddress: string | undefined
for (const candidate of mintAddresses) {
  try {
    await claims.insert({ claimedAt: new Date(), mintAddress: candidate })
    mintAddress = candidate
    break
  } catch (error) {
    // 23505 is the Postgres unique violation. Other drivers report it differently.
    if ((error as { code?: unknown } | null)?.code !== '23505') throw error
  }
}
if (!mintAddress) throw new Error('Every Seeker in this wallet has already claimed.')

// Pay out only once the insert has landed. Anything paid before it can be paid twice.
await grantReward(address)
await claims.markPaid(mintAddress)

挿入と報酬の支払いは別々の書き込みなので、2つ目が失敗した場合の動作を決めておきましょう。このままでは、行のコミット後に grantReward が例外をスローすると、そのデバイスは再試行できなくなります。制約によって、すでに請求済みとして拒否されるためです。paid_at が null の行をもとに再試行するか、grantReward を mintAddress に対して冪等にして、再実行しても重複処理されないようにしてください。 これはセキュリティではなく、堅牢性の問題です。誤ると実際の所有者が報酬を受け取れなくなりますが、誰かが2回請求できるようになるわけではありません。

関連スキル

  • solana-mobile-wallet — ウォレット接続と2種類の signIn ペイロード形式
  • seeker-domains — Seeker ユーザーがデフォルトで利用できる .skr ドメインの解決

リンク

コメント