mirror of
https://github.com/luxfi/connect.git
synced 2026-10-30 16:59:20 +00:00
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).
357 lines
11 KiB
Go
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)
|
|
}
|
|
}
|