Go 后端接入 Web3 钱包登录:nonce + personal_sign 验签

Go 后端接 Web3 钱包登录,标准流程只有四步:

  1. 后端下发 nonce
  2. 前端钱包用 personal_sign 签 “Login\nNonce: xxx”
  3. 后端 recover 出地址,比对是不是同一个
  4. 通过后签发 JWT / session

省心的做法是前端签名 + 后端验签,别在后端搞私钥。

生成 nonce

一次性、5 分钟过期、写 Redis:

package auth

import (
    "crypto/rand"
    "encoding/hex"
)

func GenerateNonce() string {
    b := make([]byte, 16)
    rand.Read(b)
    return hex.EncodeToString(b)
}

Redis key:wallet:nonce:0xabc...<nonce>,TTL 5 分钟。

前端签名

const message = `Login\nNonce: ${nonce}`;
const signature = await ethereum.request({
  method: "personal_sign",
  params: [message, address],
});

要点

  • personal_sign,不要自己算 hash
  • 消息用明文发给 MetaMask,它会自动加 EIP-191 前缀

Go 侧验签

package auth

import (
    "fmt"
    "strings"

    "github.com/ethereum/go-ethereum/common"
    "github.com/ethereum/go-ethereum/crypto"
)

func VerifySignature(address, message, sigHex string) bool {
    sigHex = strings.TrimPrefix(sigHex, "0x")
    sig := common.FromHex(sigHex)
    if len(sig) != 65 {
        return false
    }

    // 关键点 1:v 值处理(MetaMask 返回 27/28,crypto 库要 0/1)
    if sig[64] != 27 && sig[64] != 28 {
        return false
    }
    sig[64] -= 27

    // 关键点 2:EIP-191 前缀
    prefixed := fmt.Sprintf(
        "\x19Ethereum Signed Message:\n%d%s",
        len(message), message,
    )
    hash := crypto.Keccak256Hash([]byte(prefixed))

    pubKey, err := crypto.SigToPub(hash.Bytes(), sig)
    if err != nil {
        return false
    }

    recovered := crypto.PubkeyToAddress(*pubKey)
    return strings.EqualFold(recovered.Hex(), address)
}

登录 handler

type LoginReq struct {
    Address   string `json:"address"`
    Signature string `json:"signature"`
}

func Login(c *gin.Context) {
    var req LoginReq
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": "bad request"})
        return
    }

    key := "wallet:nonce:" + strings.ToLower(req.Address)
    nonce, err := redisClient.Get(ctx, key).Result()
    if err != nil {
        c.JSON(401, gin.H{"error": "nonce expired"})
        return
    }

    message := "Login\nNonce: " + nonce
    if !VerifySignature(req.Address, message, req.Signature) {
        c.JSON(401, gin.H{"error": "invalid signature"})
        return
    }

    // 防重放:立刻删掉 nonce
    redisClient.Del(ctx, key)

    token, _ := GenerateJWT(req.Address)
    c.JSON(200, gin.H{"token": token})
}

三个必踩的坑

1. 少了 EIP-191 前缀

MetaMask 的 personal_sign 会自动加:

"\x19Ethereum Signed Message:\n" + len(msg) + msg

后端验签必须手动加回这个前缀再算 keccak。少了直接验不过。

2. v 值 27/28 vs 0/1

MetaMask 返回的签名最后一个字节(v)是 27 或 28(EIP-155 前的格式)。go-ethereum 的 crypto.SigToPub 要求是 0 或 1。必须减 27

3. 忘了防重放

nonce 验完不删,攻击者抓包重放就能永远登进来。验签成功后立刻 redis.Del

建议直接上 SIWE

Sign-In with Ethereum(EIP-4361)是现在的标准,消息格式包括域名、URI、Chain ID、Issued At 等,钱包会更友好地显示:

example.com wants you to sign in with your Ethereum account:
0xabc...123

URI: https://example.com
Version: 1
Chain ID: 1
Nonce: 8a2c...
Issued At: 2026-05-10T16:31:04Z

Go 侧现成库:

go get github.com/spruceid/siwe-go
import siwe "github.com/spruceid/siwe-go"

msg, err := siwe.ParseMessage(rawMessage)
if err != nil { ... }

if _, err := msg.Verify(signature, &domain, &nonce, nil); err != nil {
    // 验签失败
}

SIWE 帮你处理消息构造、时效、chain id、防重放,比手写靠谱。

前端推荐栈

  • wagmi + viem:现在 EVM dApp 的事实标准
  • 直接 useSignMessage,签名一行搞定
  • 不用自己处理 window.ethereum

一句话总结

Web3 登录 = nonce + personal_sign + Go recover 比地址。三个坑:加 EIP-191 前缀、v 值减 27、nonce 用完立刻删。新项目直接上 SIWE,别自己拼消息格式。