Files
zeekay a96414cf40 feat: Polkadot (DOT) — the 6th login ecosystem (sr25519/ed25519/ecdsa)
Adds the full Polkadot/Substrate connector + verifier, both sides, so
@luxwallet/connect covers every Lux Bridge family (supportedChains: dot).

TS:
- types: Chain += 'polkadot'; SignatureScheme += sr25519 / ed25519-substrate /
  ecdsa-substrate. caip122 label 'Polkadot'.
- src/polkadot/connect.ts — raw window.injectedWeb3 (polkadot.js / Talisman /
  SubWallet), signRaw({type:'bytes'}); extension wraps <Bytes>…</Bytes>.
- src/polkadot/verify.ts — PURE, async (sr25519 needs cryptoWaitReady),
  fails closed. @polkadot/util-crypto (Apache-2.0; sr25519 via @scure/sr25519
  MIT) signatureVerify over the wrapped bytes + SS58 decode/checksum binding.
  Separate ./polkadot/verify entrypoint keeps the other 5 chains' verify core
  @noble-pure & synchronous.
- verify.ts: verifyProofAsync (all 6) alongside sync verifyProof (5).
- 20 vitest cases (sr25519/ed25519/ecdsa round-trip, tamper, wrong addr,
  SS58 checksum, fail-closed); 91 TS tests green.

Go (go/walletconnect/polkadot.go):
- sr25519 via github.com/oasisprotocol/curve25519-voi (BSD-3-Clause) —
  NOT the LGPL-3.0 ChainSafe/go-schnorrkel. ed25519 stdlib; ecdsa decred
  secp256k1 recover; SS58 via mr-tron/base58 + x/crypto/blake2b.
- Cross-language KATs: Go verifies the exact signatures the TS
  @polkadot/util-crypto side produced (all 3 schemes). Full Go suite green.
- Zero copyleft: go-licenses report shows every new dep BSD-3/MIT/ISC.

Licensing landmine resolved permissively (zero GPL/LGPL/AGPL).
2026-06-23 22:25:51 -07:00

357 lines
11 KiB
Go

package walletconnect
// Mirrors src/__tests__/polkadot.test.ts. Two kinds of coverage:
//
// 1. Cross-language KAT vectors: signatures produced by the TS side
// (@polkadot/util-crypto, the same wallet a browser drives) over the
// EXACT wrapped CAIP-122 message, which Go MUST verify byte-for-byte.
// If the Go verifier ever drifts from the TS verifier, these fail.
// 2. In-Go round-trips (sr25519 via curve25519-voi, ed25519 via stdlib) plus
// fail-closed hardening.
//
// sr25519 is verified with github.com/oasisprotocol/curve25519-voi
// (BSD-3-Clause) — NOT the LGPL ChainSafe/go-schnorrkel. ed25519 is stdlib;
// ecdsa is decred's secp256k1 (ISC). Zero copyleft.
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"testing"
"time"
"github.com/oasisprotocol/curve25519-voi/primitives/sr25519"
)
func mustTime(t *testing.T, s string) time.Time {
t.Helper()
tm, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
t.Fatalf("parse time %q: %v", s, err)
}
return tm
}
// --- Cross-language KAT vectors (generated by the TS @polkadot/util-crypto). ---
type substrateKAT struct {
scheme SignatureScheme
publicKey string
address string
message string
signature string
}
var (
katSr25519 = substrateKAT{
scheme: SchemeSr25519,
publicKey: "0xc07807c10dd2fefd900d2561468894974da32907fd348007b27595d798361618",
address: "5GR4hzB44z2t4j1w8WBCzrXBVKb1ZsZ5CrNYQTGCy2NzHNUF",
message: "hanzo.id wants you to sign in with your Polkadot account:\n5GR4hzB44z2t4j1w8WBCzrXBVKb1ZsZ5CrNYQTGCy2NzHNUF\n\nURI: https://hanzo.id/login\nVersion: 1\nNonce: dotNonce0001\nIssued At: 2023-11-14T22:13:20.000Z\nExpiration Time: 2023-11-14T22:23:20.000Z",
signature: "0xe43c0b36db05d11a6466af8a34718bb1e3904e72268d8d99652f8d26f66e781a969ace1ef790f852e0a043e4000d17c8e298a2760c348f8d8c42f9d64abdb183",
}
katEd25519Sub = substrateKAT{
scheme: SchemeEd25519Substr,
publicKey: "0x1417a6b97b9c16f4a4c8fb0b8fe6b5d07f8aadab205f4a8a85ce54a8c60bb5f2",
address: "5CX3qiXnfYmyZjw8bWeNaRqH19tL8XYLwhWJjWhDXbWWUioe",
message: "hanzo.id wants you to sign in with your Polkadot account:\n5CX3qiXnfYmyZjw8bWeNaRqH19tL8XYLwhWJjWhDXbWWUioe\n\nURI: https://hanzo.id/login\nVersion: 1\nNonce: dotEd2550001\nIssued At: 2023-11-14T22:13:20.000Z\nExpiration Time: 2023-11-14T22:23:20.000Z",
signature: "0x62ef4829da1ab3bae7ddc3b7bdd16b143de009714c78cf6591474aab0fcf77584584f7f8a5c85530441708ca6e86c2460bc76464c307161d74eee22cc4abdf06",
}
katEcdsaSub = substrateKAT{
scheme: SchemeEcdsaSubstr,
publicKey: "0x0399c261258bf7bccb4cefa51844535a79d5af1900cf58f470a119a20d2ffe1e81",
address: "5FikXD6JrCF2GZghwcQbVVDURDN9mR6K34mGWQ1azXysVH1x",
message: "hanzo.id wants you to sign in with your Polkadot account:\n5FikXD6JrCF2GZghwcQbVVDURDN9mR6K34mGWQ1azXysVH1x\n\nURI: https://hanzo.id/login\nVersion: 1\nNonce: dotEcdsa0001\nIssued At: 2023-11-14T22:13:20.000Z\nExpiration Time: 2023-11-14T22:23:20.000Z",
signature: "0x836c2329d04a33e1d9c6cd80d9a5926cc83335df0bc56d8c121d01ccca36455143fd90009f149c6d9a3b3f8abbcb5aad7414b605991cc4400227a5c745d6099d01",
}
)
func (k substrateKAT) proof() Proof {
return Proof{
Chain: ChainPolkadot,
Scheme: k.scheme,
Address: k.address,
PublicKey: k.publicKey,
Message: k.message,
Signature: k.signature,
}
}
func TestPolkadotKAT_CrossLanguage(t *testing.T) {
for _, k := range []substrateKAT{katSr25519, katEd25519Sub, katEcdsaSub} {
k := k
t.Run(string(k.scheme), func(t *testing.T) {
if !VerifyPolkadot(k.proof()) {
t.Fatalf("VerifyPolkadot(%s KAT) = false, want true (TS↔Go drift)", k.scheme)
}
})
}
}
func TestPolkadotKAT_RejectsTamperedMessage(t *testing.T) {
for _, k := range []substrateKAT{katSr25519, katEd25519Sub, katEcdsaSub} {
k := k
t.Run(string(k.scheme), func(t *testing.T) {
p := k.proof()
p.Message += " "
if VerifyPolkadot(p) {
t.Fatalf("VerifyPolkadot(%s tampered message) = true, want false", k.scheme)
}
})
}
}
func TestPolkadotKAT_RejectsFlippedSignatureBit(t *testing.T) {
for _, k := range []substrateKAT{katSr25519, katEd25519Sub, katEcdsaSub} {
k := k
t.Run(string(k.scheme), func(t *testing.T) {
p := k.proof()
sig := mustHex(t, p.Signature[2:]) // strip 0x
sig[0] ^= 0x01
p.Signature = "0x" + hex.EncodeToString(sig)
if VerifyPolkadot(p) {
t.Fatalf("VerifyPolkadot(%s flipped sig) = true, want false", k.scheme)
}
})
}
}
func TestPolkadotKAT_RejectsWrongAddress(t *testing.T) {
// sr25519 proof with ed25519's (valid SS58, but wrong) address.
p := katSr25519.proof()
p.Address = katEd25519Sub.address
if VerifyPolkadot(p) {
t.Fatal("VerifyPolkadot(wrong address) = true, want false")
}
}
func TestPolkadotKAT_RejectsMismatchedPublicKey(t *testing.T) {
p := katSr25519.proof()
// Another valid sr25519 key, but not the signer/address.
p.PublicKey = "0x" + hex.EncodeToString(make([]byte, 32))
if VerifyPolkadot(p) {
t.Fatal("VerifyPolkadot(mismatched pubkey) = true, want false")
}
}
func TestPolkadotKAT_RejectsCorruptedSS58Checksum(t *testing.T) {
p := katSr25519.proof()
a := []byte(p.Address)
if a[len(a)-1] == 'A' {
a[len(a)-1] = 'B'
} else {
a[len(a)-1] = 'A'
}
p.Address = string(a)
if VerifyPolkadot(p) {
t.Fatal("VerifyPolkadot(corrupted SS58 checksum) = true, want false")
}
}
// --- In-Go round-trips (independent of the TS-minted KATs). ---
// substrateSS58Encode encodes a 32-byte AccountId to an SS58 address with the
// given 1-byte network prefix. Independent of the verifier's decodeSS58 (sign
// side vs verify side), so drift surfaces as a failing round-trip.
func substrateSS58Encode(t *testing.T, accountID []byte, prefix byte) string {
t.Helper()
body := append([]byte{prefix}, accountID...)
preimage := append(append([]byte{}, ss58Pre...), body...)
full := blake2b512(preimage)
full2 := full[:ss58ChecksumLen]
raw := append(append([]byte{}, body...), full2...)
return base58EncodeBitcoin(raw)
}
// base58EncodeBitcoin is a tiny Bitcoin-alphabet base58 encoder for the test
// SS58 encoder (the verifier only decodes, via mr-tron/base58).
func base58EncodeBitcoin(b []byte) string {
const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
zeros := 0
for zeros < len(b) && b[zeros] == 0 {
zeros++
}
// base-256 -> base-58
digits := []int{0}
for i := zeros; i < len(b); i++ {
carry := int(b[i])
for j := 0; j < len(digits); j++ {
carry += digits[j] << 8
digits[j] = carry % 58
carry /= 58
}
for carry > 0 {
digits = append(digits, carry%58)
carry /= 58
}
}
out := make([]byte, 0, zeros+len(digits))
for i := 0; i < zeros; i++ {
out = append(out, '1')
}
for i := len(digits) - 1; i >= 0; i-- {
out = append(out, alphabet[digits[i]])
}
return string(out)
}
func testPolkadotMessage(t *testing.T, address string) string {
t.Helper()
msg, err := BuildSiwxMessage(BuildParams{
Challenge: LoginChallenge{
Domain: "hanzo.id",
URI: "https://hanzo.id/login",
Nonce: "0123456789abcdef0123456789abcdef",
IssuedAt: "2026-01-01T00:00:00.000Z",
},
Address: address,
Chain: ChainPolkadot,
})
if err != nil {
t.Fatalf("BuildSiwxMessage: %v", err)
}
return msg
}
func TestPolkadotSr25519RoundTripInGo(t *testing.T) {
msk, err := sr25519.GenerateMiniSecretKey(rand.Reader)
if err != nil {
t.Fatalf("sr25519 keygen: %v", err)
}
kp := msk.ExpandEd25519().KeyPair()
pub, err := kp.PublicKey().MarshalBinary()
if err != nil {
t.Fatalf("marshal pubkey: %v", err)
}
address := substrateSS58Encode(t, pub, 42)
message := testPolkadotMessage(t, address)
ctx := sr25519.NewSigningContext(substrateSigningContext)
sig, err := kp.Sign(rand.Reader, ctx.NewTranscriptBytes(wrapBytes(message)))
if err != nil {
t.Fatalf("sr25519 sign: %v", err)
}
sigBytes, err := sig.MarshalBinary()
if err != nil {
t.Fatalf("marshal sig: %v", err)
}
p := Proof{
Chain: ChainPolkadot,
Scheme: SchemeSr25519,
Address: address,
PublicKey: "0x" + hex.EncodeToString(pub),
Message: message,
Signature: "0x" + hex.EncodeToString(sigBytes),
}
if !VerifyPolkadot(p) {
t.Fatal("VerifyPolkadot(in-Go sr25519 round-trip) = false, want true")
}
// tamper
p.Message += "x"
if VerifyPolkadot(p) {
t.Fatal("VerifyPolkadot(tampered in-Go sr25519) = true, want false")
}
}
func TestPolkadotEd25519RoundTripInGo(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 keygen: %v", err)
}
address := substrateSS58Encode(t, pub, 42)
message := testPolkadotMessage(t, address)
sig := ed25519.Sign(priv, wrapBytes(message))
p := Proof{
Chain: ChainPolkadot,
Scheme: SchemeEd25519Substr,
Address: address,
PublicKey: "0x" + hex.EncodeToString(pub),
Message: message,
Signature: "0x" + hex.EncodeToString(sig),
}
if !VerifyPolkadot(p) {
t.Fatal("VerifyPolkadot(in-Go ed25519 round-trip) = false, want true")
}
p.Message += "x"
if VerifyPolkadot(p) {
t.Fatal("VerifyPolkadot(tampered in-Go ed25519) = true, want false")
}
}
// --- fail-closed hardening ---
func TestPolkadotRejectsUnknownScheme(t *testing.T) {
p := katSr25519.proof()
p.Scheme = SchemeEd25519 // not a substrate scheme
if VerifyPolkadot(p) {
t.Fatal("VerifyPolkadot(unknown scheme) = true, want false")
}
}
func TestPolkadotDoesNotPanicOnGarbage(t *testing.T) {
garbage := Proof{
Chain: ChainPolkadot,
Scheme: SchemeSr25519,
Address: "!!!notss58!!!",
PublicKey: "nothex",
Message: "not a siwx message",
Signature: "!!!!",
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("VerifyPolkadot panicked on garbage: %v", r)
}
}()
if VerifyPolkadot(garbage) {
t.Fatal("VerifyPolkadot(garbage) = true, want false")
}
}
func TestPolkadotFailsClosedOnMissingPublicKey(t *testing.T) {
p := katSr25519.proof()
p.PublicKey = ""
if VerifyPolkadot(p) {
t.Fatal("VerifyPolkadot(missing pubkey) = true, want false")
}
}
func TestPolkadotRejectsWrongLengthPublicKey(t *testing.T) {
p := katSr25519.proof()
p.PublicKey = "0x" + hex.EncodeToString(make([]byte, 31))
if VerifyPolkadot(p) {
t.Fatal("VerifyPolkadot(31-byte pubkey) = true, want false")
}
}
// --- end-to-end via VerifyProof (the one entry point) ---
func TestPolkadotVerifyProofEndToEnd(t *testing.T) {
res := VerifyProof(katSr25519.proof(), Expectation{
Domain: "hanzo.id",
Nonce: "dotNonce0001",
Now: mustTime(t, "2023-11-14T22:14:00.000Z"),
})
if !res.OK {
t.Fatalf("VerifyProof(sr25519 KAT) not OK: reason=%s", res.Reason)
}
if res.Chain != ChainPolkadot {
t.Fatalf("chain = %q, want polkadot", res.Chain)
}
if res.Address != katSr25519.address {
t.Fatalf("address = %q, want %q", res.Address, katSr25519.address)
}
}
func TestPolkadotVerifyProofRejectsWrongNonce(t *testing.T) {
res := VerifyProof(katSr25519.proof(), Expectation{
Domain: "hanzo.id",
Nonce: "WRONG",
Now: mustTime(t, "2023-11-14T22:14:00.000Z"),
})
if res.Reason != ReasonNonceMismatch {
t.Fatalf("reason = %q, want nonce-mismatch", res.Reason)
}
}