PHP (Laravel)でパスワードレスのMetaMask認証

ユーザー名とパスワードでログインすることは昔から標準になっていますが、他にも選択肢があります。ブラウザ拡張式ウォレットを使用することで、パスワードレスのログインを紹介します。

今回はMetaMaskというウォレットを使用します。Laravelで、usersテーブルにwallet_addressとnonceを追加する必要がります。ウォレットアドレスはユーザーにユニークなウォレットのIDになります。ユーザー名と同じような役割になります。nonceはランダムな文字列になり、認証時に必要な値になります。

APIは二つのエンドポイントが必要になります。

initiate – こちらはnonceを生成する役割のAPIになります。ランダムな文字列を生成し、wallet_addressを受けて、usersテーブルで検索し、nonceを引っかかったユーザーに保存します。そのwallet_addressを持っているユーザーが存在しない場合、新規登録し、nonceを設定する。

コードの例:

public function initiate(LoginInitateRequest $request) {
        $input = $request->validated();
        $address = $input['wallet_address'];
        if ($address) {
            $nonce = strval(rand(1000000000, 21474836470));
            $user = User::firstOrNew(
                ['wallet_address' => strtolower($address)]
            );
            if($user) {
                $user->nonce = $nonce;
                $user->save();
            }
            else {
                $user = new User();
                $user->wallet_address = $address;
                $user->nonce = $nonce;
                $user->save();
            }
        }
        $message = "Wallet: {$address}\n\nNonce: {$nonce}";
        return response()->json(['message' => $message]);
    }

login – signatureとwallet_addressを受ける。signatureは以前生成したnonceと固定の文字列をMetaMaskに渡して、MetaMaskがサインした値になります。signatureを解読し、ウォレットアドレスの値になります。解読した後のウォレットアドレスと実際に渡されたウォレットアドレスがイコールであれば、認証する。

コードの例:

    public function login(LoginRequest $request) {
        $input = $request->validated();
        $address = $input['wallet_address'];
        $signature = $input['signature'];
        $user = User::firstOrNew(
            ['wallet_address' => strtolower($address)]
        );
        if(!$user) {
            $message = "Wallet: {$address}\n\nNonce: {$user->nonce}";

            if ($this->verifySignature($message, $signature, $address)) {
                $token = auth()->login($user);
                return response()->json([
                    'message' => 'SUCCESS',
                    'auth' => $token
                ]);
            }
        }
        return response()->json(['message' => 'ERROR']);
    }


    private function pubKeyToAddress($pubkey)
	{
		return "0x" . substr(Keccak::hash(substr(hex2bin($pubkey->encode("hex")), 1), 256), 24);
	}

	private function verifySignature($message, $signature, $address)
	{
		$length = strlen($message);
		$hash   = Keccak::hash("\x19Ethereum Signed Message:\n{$length}{$message}", 256);
		$sign   = [
			"r" => substr($signature, 2, 64),
			"s" => substr($signature, 66, 64)
		];
		$recid  = ord(hex2bin(substr($signature, 130, 2))) - 27;
		if ($recid != ($recid & 1))
			return false;

		$ec = new EC('secp256k1');
		$pubkey = $ec->recoverPubKey($hash, $sign, $recid);

		return strtolower($address) == strtolower($this->pubKeyToAddress($pubkey));
	}

メタマスクの中のプライベートキーでメッセージをサインするので、ユーザーのブラウザの中でプライベートキーでサインされて、値とウォレットアドレス(パブリックキー)をサーバに投げて、パブリックキーで解読する仕組みです。メッセージがランダムなので、宣言しているウォレットアドレスが正しくパブリックキーの役割を果たせるのであれば、実際にそのウォレットアドレスの所有者と判断して大丈夫です。

ウォレットと接続するための例:

const [address] = await web3.eth.requestAccounts();

既に接続している場合ウォレットのアドレス取得

const wallet = web3.currentProvider.selectedAddress;

メタマスクのコードは以下のようになります。 messageはinitiateのapiから取得した値です。

const signature = await web3.eth.personal.sign(message, walletAddress);

ユーザーがMetaMaskをインストールしている前提の認証なので、ユースケースは限られていますが、id/pw以外の方法としての紹介でした。