Как проверить, что владелец учетной записи MetaMask является реальным владельцем адреса?

Я делаю dapp, который будет звонить на сервер Node.js. Я ожидаю, что у пользователя будет установлен MetaMask, и я хочу убедиться, что он является реальным владельцем текущего адреса в MetaMask (т.е. accounts[0]).

Это пользовательский поток, который я пытаюсь реализовать:

  1. Пользователь загружает мой интерфейс dapp в своем браузере.
  2. Браузер получает accounts[0]от Web3/MetaMask.
  3. Внешний интерфейс запрашивает некоторые данные, относящиеся к accounts[0]моему Node.js API.
  4. На сервере Node.js мне нужно убедиться, что запрос исходит от кого-то, кто действительно владеет приватными ключами на адрес accounts[0]. Если это действительно так, то я отвечаю конкретными данными.

Я долго изучал различные функции подписи в Web3 и в итоге очень запутался. Есть:

  • web3.eth.sign- recoverаналога этому нет, и MetaMask не выскакивает с просьбой что-то подписать.
  • web3.eth.personal.sign- для этого требуется пароль, я не хочу просить пользователя вводить пароль, разве MetaMask не должен этого делать?
  • web3.eth.accounts.sign- это больше похоже на хеш-функцию, чем на то, что мне нужно.

У меня такое ощущение, что ни одна из трех вышеперечисленных функций мне не нужна. Может ли кто-нибудь дать некоторые рекомендации о том, как подойти к этому?

Я думаю, что решение больше не работает. По крайней мере, я не получаю правильный публичный адрес. Не могли бы вы помочь мне узнать, как это должно быть сделано?
Ответы на этой странице устарели. web3больше не доступен на странице с использованием метамаски.

Ответы (5)

Я думаю web3.eth.sign, это то, что вы хотите, но обратите внимание, что он ожидает 32-байтовую строку (обычно хэш сообщения).

Это сработало для меня:

web3.eth.sign(web3.eth.defaultAccount, web3.sha3('test'), function (err, signature) {
  console.log(signature);  // But maybe do some error checking. :-)
});

Затем на сервере с помощью ethereumjs-util:

const util = require('ethereumjs-util');
const sig = util.fromRpcSig('<signature from front end>');
const publicKey = util.ecrecover(util.sha3('test'), sig.v, sig.r, sig.s);
const address = util.pubToAddress(publicKey).toString('hex');

Вы сказали, что «MetaMask не выскакивает с просьбой что-то подписать», но это должно было быть. Если он по-прежнему не работает, поделитесь кодом, который вы используете для вызова web3.eth.sign.

РЕДАКТИРОВАТЬ

Обратите внимание, что в web3.js 1.0 web3.util.sha3вместо web3.sha3.

это было очень полезно, спасибо. Мне пришлось внести два изменения, чтобы заставить его работать (поскольку я использую Web3 1.0): (1) web3.utils.sha3вместо web3.sha3, (2) fromRpcSig()мне нужно было передать все это с начальным 0x. Если вы добавите это в редактирование, я приму ваш ответ.
Спасибо, интерлиньяж 0xдействительно необходим (независимо от того, какую версию web3.js вы используете). Я отредактировал ответ.
это безопасно? Должна ли строка генерироваться случайным образом с сервера или она может быть жестко запрограммирована на переднем и заднем концах?

Что я могу сказать по своему опыту, так это то, что ни один из приведенных выше ответов не работал у меня из коробки. Вот как я это реализовал (проверено с помощью MetaMask и WalletConnect)

Фронтенд веб-приложение (ReactApp)

Задача состоит в том, чтобы подписать одноразовый номер (случайную строку), предоставленный сервером. Вот как может потребоваться безопасный знак

let sign = await web3.eth.personal.sign(nonce, walletAddress, "")

Где:

  • web3является экземпляром web3 (в настоящее время @ 1.3.6)
  • nonceслучайная строка для подписи (передается как простая строка)
  • walletAddressадрес кошелька в виде строки (например, 0xaabb....ccdd)

Созданный знак затем отправляется как есть на серверную часть.

Верификация серверной части

Цель состоит в том, чтобы извлечь из подписи адрес кошелька, подписавшего запрос. Таким образом, нет никакого способа подделать это.

import * as util from "ethereumjs-util";

nonce = "\x19Ethereum Signed Message:\n" + nonce.length + nonce
nonce = util.keccak(Buffer.from(nonce, "utf-8"))
const { v, r, s } = util.fromRpcSig(signature)
const pubKey = util.ecrecover(util.toBuffer(nonce), v, r, s)
const addrBuf = util.pubToAddress(pubKey)
const addr = util.bufferToHex(addrBuf)

Когда мы подписываем одноразовый номер в браузере, перед одноразовым номером автоматически добавляется заполняющее сообщение, поэтому нам нужно заново создать его на стороне сервера, чтобы проверить его.

Здесь мы воссоздаем одноразовый номер, затем извлекаем открытый ключ подписавшего и получаем адрес кошелька из открытого ключа. Если этот адрес совпадает с ожидаемым , пользователь является владельцем закрытого ключа, связанного с извлеченным адресом.

ethereum-jsбыл пользователем версии 7.0.10 ( пакет npm )

В чем разница между подписью и одноразовым номером на задней панели? откуда подпись??
nonce— это случайная строка для подписи, которая генерируется серверной частью и отправляется во внешний интерфейс для подписи. Signatureявляется результатом операции со знаком и в нашем случае генерируется с помощью менеджера кошелька, такого как Metamask. Это не просто концепции, связанные с web3. Я предлагаю вам прочитать больше об этой теме, например stackoverflow.com/questions/4751172/…
Таким образом, считается небезопасным использование одной и той же строки на переднем и заднем концах, а не ее случайное генерирование?
Каждый одноразовый номер должен быть случайным и уникально сгенерированным серверной частью, иначе у вас возникнут проблемы с безопасностью, поскольку я могу бесконечно использовать украденный одноразовый номер, подписанный другим пользователем.
на стороне клиента это код, который мне нужно было использовать сейчас, который window.web3устарел в пользу window.ethereum:window.ethereum.sendAsync({method: 'personal_sign', params: [window.ethereum.selectedAddress, nonce], from: window.ethereum.selectedAddress}, (err, result) => { console.log('signature', result.result) });
Вы можете просто использовать ethers.utils.verifyMessage(сообщение,подпись)

Собственный web3 MetaMask, все еще 0.2 по состоянию на май 2018 года, синтаксис знака -

console.log(web3.version.api);
web3.personal.sign(web3.toHex("message to sign"), accounts[0], 
                   function(err, res) {
    // whatever
});

Если вы замените web3 MetaMask на web3.js 1.0.0 (бета), используйте следующий синтаксис --

window.web3 = new Web3(web3.currentProvider);
console.log(web3.version);
web3.eth.personal.sign('message to sign', accounts[0])
.then(signature => {
    // whatever
});

В обоих случаях MetaMask выведет всплывающее уведомление о подписи.

Я реализовал это, и это прекрасно работает. Я использую Python/Flask в бэкэнде, поэтому вам нужно будет найти эквивалентный бэкэнд-код для Node:

Серверная часть: храните пользователя по его общедоступному адресу в базе данных вместе с одноразовым номером, используемым для входа.

Самая простая схема для Пользователя/Учетной записи:

public_address = db.Column(db.String(80), primary_key=True, nullable=False, unique=True)
nonce = db.Column(
    INTEGER(unsigned=True),
    nullable=False,
    default=generate_nonce,
)

Где generate nonce — это генератор псевдослучайных чисел, например:

def generate_nonce(self, length=8):
    return ''.join([str(randint(0, 9)) for i in range(length)])

Frontend GET и подписывает одноразовый номер с помощью web3

Получить публичный адрес текущего пользователя:

web3.eth.getAccounts()
        .then((response) => {
            const publicAddressResponse = response[0];

            if (!(typeof publicAddressResponse === "undefined")) {
                setPublicAddress(publicAddressResponse);
                getNonce(publicAddressResponse);
            }
        })
        .catch((e) => {
            console.error(e);
        });

Внешний интерфейс должен выполнить запрос GET, чтобы получить текущий одноразовый номер для публичного адреса, который пытается войти в систему. Если учетная запись еще не существует, создайте ее и все равно верните одноразовый номер:

GET /api/users?publicAddress=${publicAddress}

а затем подпишите одноразовый номер с помощью Metamask:

web3.eth.personal.sign(`I am signing my one-time nonce: ${nonce}`, publicAddress, "test password!")
            .then((signature) => {
                handleAuth(publicAddress, signature)
            });

Затем внешний интерфейс отправляет подписанный одноразовый номер на серверную часть для получения JWT.

Внешний интерфейс:

axios.post(props.config.serverUrl + '/sessions/', {
            publicAddress: publicAddress,
            signature: signature,
            auth_type: 'ethereum',
        })
            .then((response) => {
                localStorage.setItem('accessToken', response.data.access_token);
            })
            .catch((e) => {
                console.error(e);
            });

Затем на серверной части вы аутентифицируете, что подпись пришла с этого общедоступного адреса, используя библиотеки web3, и выдаете JWT, если аутентифицированы. Оттуда это просто обычное управление сеансом с использованием JWT, что не является проблемой web3:

@sessions_blueprint.route('/sessions/', methods=['POST'])
def create_session():

    auth_type = request.json.get('auth_type', AuthType.EMAIL)

    public_address = request.json['publicAddress']
    signature = request.json['signature']

    account = EthereumAccount.query.filter_by(public_address=public_address).first()

    if account is None:
        abort(404, 'Public address not registered.')

    original_message = 'I am signing my one-time nonce: {}'.format(account.nonce)
    message_hash = defunct_hash_message(text=original_message)
    signer = w3.eth.account.recoverHash(message_hash, signature=signature)

    if signer == public_address:
        account.nonce = account.generate_nonce()
        db.session.commit()
    else:
        abort(401, 'could not authenticate signature')

    access_token = create_access_token(identity=public_address)
    refresh_token = create_refresh_token(identity=public_address)


    return jsonify({
        'access_token': access_token,
        'refresh_token': refresh_token,
    }), 200

И вуаля! Теперь пользователь аутентифицирован с помощью JWT на стороне клиента. Вы можете управлять сеансами и авторизацией маршрутов на бэкенде, как обычно.

Я узнал, как это сделать, из этой статьи: https://www.toptal.com/ethereum/one-click-login-flows-a-metamask-tutorial

Я объясню в next.js для дальнейшего использования:

1- Сервер должен создать сообщение, создать сеанс, и клиент запросит это и сохранит в файлах cookie. Использование пакета iron-session npm упрощает создание сеанса, а также сохранение файла cookie в next.js.

  if (req.method === "GET") {
      try {
        // message can be anything. I use id as password
        const message = { contractAddress, id: uuidv4() };
        req.session.messageSession = {
          ...message,
        };
        await req.session.save();
        return res.json(message);
      } catch (error) {
        res.status(422).send({ message: "Cannot generate a message" });
      }

2- Пользователь сделает GETзапрос. Если вы создаете nft, вы должны отправить данные json и изображение, для обоих вам необходимо выполнить процесс проверки. Вот почему я пишу повторно используемую функцию для получения данных и их подписи:

const createSignedData = async () => {
    const messageToSign = await axios.get("/api/verify");
    const accounts = (await ethereum?.request({
      method: "eth_requestAccounts",
    })) as string[];
    // account will be the signer of this message
    const account = accounts[0];
    // password is the third param as uuid
    const signedData = await ethereum?.request({
      method: "personal_sign",
      params: [
        JSON.stringify(messageToSign.data),
        account,
        messageToSign.data.id,
      ],
    });
    return { signedData, account };
  };

3- После того, как пользователь создает подписанные данные, он отправляет POSTзапрос на сервер:

const createNft = async () => {
    try {
      const { account, signedData } = await createSignedData();
      await axios.post("/api/verify", {
        address: account,
        signature: signedData,
        nft: nftMeta,
      })
    } catch (error: any) {
      console.log("error in createnft", error);
    }
  };

4- Сервер получит подписанные данные, теперь время проверки. для этого я пишу промежуточное ПО:

  export const addressVerificationMiddleware = async (
      req: NextApiRequest,
      res: NextApiResponse
    ) => {
      return new Promise(async (resolve, reject) => {
        const message = req.session.messageSession;
        // nonce is the representation of something that we are going to sign
        let nonce: string | Buffer =
          "\x19Ethereum Signed Message:\n" +
          JSON.stringify(message).length +
          JSON.stringify(message);
    
        nonce = util.keccak(Buffer.from(nonce, "utf-8"));
        const { v, r, s } = util.fromRpcSig(req.body.signature);
        // matching signature with the unsigned message
        const pubKey = util.ecrecover(util.toBuffer(nonce), v, r, s);
        const addressBuffer = util.pubToAddress(pubKey);
        const address = util.bufferToHex(addressBuffer);
        if (address === req.body.address) {
          resolve("Correct Address");
        } else {
          reject("Wrong Address");
        }
      });
    };

Я создаю конечную точку и использую вышеуказанное промежуточное ПО.

if (req.method === "POST") {
      try {
        const { body } = req;
        const nft = body.nft as NftMeta;
        if (!nft.name || !nft.description || !nft.attributes) {
          return res.status(422).send({ message: "Form data is missing" });
        }
        // addressCheckMiddleware
        await addressVerificationMiddleware(req, res);
        const url = `https://api.pinata.cloud/pinning/pinJSONToIPFS`;
        const jsonResponse = await axios.post(
          url,
          {
            pinataMetadata: {
              name: uuidv4(),
            },
            pinataContent: nft,
          },
          {
            headers: {
              pinata_api_key: pinataApiKey,
              pinata_secret_api_key: pinataSecretApiKey,
            },
          }
        );
        return res.status(200).send(jsonResponse.data);
      } catch (error) {
        console.error("error in verify post req", error);
        res.status(422).send({ message: "Cannot create JSON" });
      }