📘

署名を利用してウォレットアドレスが本人のものか確認する

2022/02/28に公開

MetaMaskなどのウォレットを使ってログインできるDappsを作るときに、バックエンドに送られてくるウォレットアドレスが本人のものか確認したいことがある。

これを実現するためにはユーザーのウォレットの署名を利用すれば良い。

流れ

流れとしては下記のような感じ。

1). バックエンドからワンタイムトークンを発行する
2). ワンタイムトークンをウォレット(の秘密鍵)で署名してもらう
3). バックエンドにウォレットアドレスと署名されたメッセージを送信する
4). バックエンド側で署名を検証し、送信されてきたウォレットアドレスが本人のものかを確認する

デモページ

一応デモページを用意した。ウォレットアドレスと署名が正しい組み合わせの場合は検証成功の文字が表示される。もちろんMetaMask等のウォレットがないと動かない。ちなみに手抜きで正誤判定をリダイレクトで実装してるんだけどデモなので許して...
https://razokulover.com/playgrounds/personal_sign

実際のコード

Blitz.jsで書いてるので読みづらいかもしれないが、ほぼReactやNext.jsと変わらないので適宜読み替えてほしい。

ライブラリとしてはethers.jsを利用した。

トークン生成のAPI: GET /api/get_signer_token

ワンタイムトークンを作って返すだけ。DBに保存とかするとコードが複雑になってしまうので環境変数にあるsaltで雑にそれっぽいトークンを生成している。本番ではちゃんと書かないとだめ。

import { BlitzApiRequest, BlitzApiResponse } from "next"
import crypto from "crypto"
 
const handler = async (req: BlitzApiRequest, res: BlitzApiResponse) => {
   if (req.method !== "GET") {
     return res.status(404).end()
   }
 
   if (!req.query.address) {
     return res.status(400).end()
   }
 
   // 簡便のためとりあえず固定で生成している。好きな方法で生成して貰えば良い。
   const token = `${req.query.address}${process.env.SIGNER_TOKEN_SALT}`
   return res.end(JSON.stringify({ token: crypto.createHash("sha256").update(token).digest("hex") }))
}
 
export default handler

署名検証のAPI: GET /api/verify_message

utils.verifyMessageで送られてきた署名からアドレスを復元する。このアドレスがAPIに送られてきたウォレットアドレスと一致するかを確認する。署名はウォレットの秘密鍵で行われるので、それを知っているユーザーでなければ正しいウォレットアドレスは生成できない。

 import { BlitzApiRequest, BlitzApiResponse } from "next"
 import crypto from "crypto"
 import { utils } from "ethers"

 const getMessage = (address: string) => {
   const token = crypto
     .createHash("sha256")
     .update(`${address}${process.env.SIGNER_TOKEN_SALT}`)
     .digest("hex")
   const bytes = utils.toUtf8Bytes(token)
   const digest = utils.keccak256(bytes)
   return utils.arrayify(digest)
 }

 const handler = async (req: BlitzApiRequest, res: BlitzApiResponse) => {
   if (req.method !== "GET") {
     return res.status(404).end()
   }

   if (!req.query.address || !req.query.sig || req.query.address === "" || req.query.sig === "") {
     return res.status(400).end()
   }

   const address = req.query.address.toString()
   const signature = req.query.sig.toString()

   // ここで署名からウォレットアドレスを復元してる
   const recoveredAddress = utils.verifyMessage(getMessage(address), signature)

   // 送信されてきたウォレットアドレスと復元したウォレットアドレスが同じならresultがtrueになる
   res.end(JSON.stringify({result: address === recoveredAddress}))
 }

 export default handler

フロントエンドのページ

1ページにまとめて書いてるので見づらいかもしれない。本質的なコードはgetSignerTokenverifyMessageのところ。getSignerTokenでワンタイムトークンを取得している。ボタンを押すとverifyMessageが発火し、ブラウザのウォレットが立ち上がる。ワンタイムトークンが表示され、署名しますか?というアラートが出る。これを許可するとバックエンドに署名とウォレットアドレスが送信されて署名の検証が行われるという感じ。

 import { Head, BlitzPage, useRouter } from "blitz"
 import Layout from "app/core/layouts/Layout"
 import { utils, providers } from "ethers"
 import { useEffect, useState } from "react"
 import { Box, Button, Flex, Text } from "@chakra-ui/react"

 const MainContent = () => {
   const [signerToken, setSignerToken] = useState("")
   useEffect(() => {
     if (window) {
       getSignerToken()
     }
   }, [])

   const getSignerToken = async () => {
     const provider = new providers.Web3Provider((window as any).ethereum)
     const signer = provider.getSigner()
     const address = await signer.getAddress()
     await fetch(`/api/playgrounds/get_signer_token?address=${address}`, {
       method: "GET",
       headers: { "Content-Type": "application/json" },
     })
       .then((res) => res.json())
       .then((res) => {
         if (res) {
           setSignerToken(res.token)
         }
       })
   }

   const verifyMessage = async () => {
     const provider = new providers.Web3Provider((window as any).ethereum)
     const signer = provider.getSigner()
     const address = await signer.getAddress()
     const bytes = utils.toUtf8Bytes(signerToken)
     const digest = utils.keccak256(bytes)
     const bin = utils.arrayify(digest)
     const signature = await signer.signMessage(bin)
     await fetch(`/api/playgrounds/verify_message?address=${address}&sig=${signature}`, {
       method: "GET",
       headers: { "Content-Type": "application/json" },
     })
       .then((res) => res.json())
       .then((res) => {
         if (res && res.result) {
           console.log("ウォレットアドレスは本人のものです。")
         } else {
           console.log("ウォレットアドレスは本人のものではありません。")
         }
       })
   }

   return (
     <Flex
       maxW={"50vw"}
       minH={"100vh"}
       flexDir={"column"}
       m={"0 auto"}
       justifyContent={"center"}
       alignItems={"center"}
     >
       <Box mb={8}>
         <Button onClick={verifyMessage}>メッセージの署名</Button>
       </Box>
     </Flex>
   )
 }

 const PersonalSignPage: BlitzPage = () => {
   return <MainContent />
 }

 PersonalSignPage.authenticate = false
 PersonalSignPage.getLayout = (page) => <Layout>{page}</Layout>

 export default PersonalSignPage

以上。

Discussion