mirror of
https://github.com/luxwallet/connect.git
synced 2026-10-30 19:32:42 +00:00
Every verifier + the dispatcher now fails closed under any malformed/ adversarial input, never throws/panics, and resists DoS. No new dependency; prod runtime tree stays 4 deps, all MIT (zero copyleft). - src/limits.ts <-> go/walletconnect/limits.go: single bounds module — size caps (message/sig/pubkey/address/extra/lines) + chain<->scheme table (chainAllowsScheme) that rejects every illegitimate (chain,scheme) pair up front, closing cross-chain scheme confusion by construction. sig cap = 20 KiB so it clears a Cardano COSE_Sign1 (embeds the payload, ~2x the message) for a large-but-legal message. - dispatcher gate() + total backstop (TS try/catch, Go defer recover) around verifyProof / verifyProofAsync so any throw/panic below -> rejected proof. - per-verifier input bounds before any hash/decode (verifiers are exported, so each is independently safe, not just via dispatch). - caip122 parse: bound message size + line count before split. - strict base64 in bytes.ts (was lenient atob) — also kills a latent TS/Go divergence (Go StdEncoding is strict). - CBOR depth cap (MAX_CBOR_DEPTH=16) + declared-length<=remaining guard both langs; in Go a stack overflow is fatal/non-recoverable so this is load-bearing. - TON workchain range-checked to int32 in TS (Go already did). Tests: 160 TS (was 112) incl fuzz.test.ts (47, 30k+ random inputs) + Go property/corpus + 3 native Fuzz* (610k/1.2M/1.0M execs clean). Invariants: (A) never throws/panics, (B) never ok for a non-valid proof, with a ranCrypto>0 gate proving the fuzzers reach the crypto layer. All prior tests stay green. Bump @luxwallet/connect 0.1.0 -> 0.1.1.
263 lines
8.8 KiB
Go
263 lines
8.8 KiB
Go
package walletconnect
|
||
|
||
// Polkadot (Substrate) verifier — wallet login-message signatures.
|
||
//
|
||
// Substrate accounts sign with one of three schemes; the account's key type
|
||
// decides which:
|
||
// - sr25519 : Schnorrkel/Ristretto255 (the Polkadot default).
|
||
// - ed25519-substrate : Ed25519 accounts.
|
||
// - ecdsa-substrate : secp256k1 accounts. The public key is blake2b-256
|
||
// hashed to a 32-byte AccountId before SS58 encoding, and the signature is
|
||
// a 65-byte recoverable ECDSA over blake2b-256(message).
|
||
//
|
||
// Two independent checks, both of which must hold (decomplected):
|
||
//
|
||
// 1. Signature: valid over the message a polkadot.js-compatible wallet's
|
||
// signRaw({type:'bytes'}) actually signs — i.e. the CAIP-122 message
|
||
// WRAPPED as "<Bytes>…</Bytes>" (the extension U8A_WRAP_* wrapper).
|
||
// - sr25519 : Schnorrkel verify, signing context "substrate",
|
||
// transcript over the wrapped bytes.
|
||
// - ed25519-substrate : raw EdDSA over the wrapped bytes.
|
||
// - ecdsa-substrate : recover the secp256k1 key from the 65-byte sig
|
||
// over blake2b-256(wrapped) and require it equals proof.PublicKey.
|
||
// 2. Address binding: the claimed SS58 address decodes (valid SS58 checksum)
|
||
// to the AccountId the public key derives — sr25519/ed25519 use the 32-byte
|
||
// public key directly; ecdsa uses blake2b-256(pubkey).
|
||
//
|
||
// Pure: no I/O, no clock. Fails closed — every error path returns false,
|
||
// nothing panics. Mirrors src/polkadot/verify.ts 1:1.
|
||
//
|
||
// sr25519 is provided by github.com/oasisprotocol/curve25519-voi (BSD-3-Clause,
|
||
// permissive — NOT the LGPL ChainSafe/go-schnorrkel). ed25519 is stdlib;
|
||
// ecdsa is decred's secp256k1 (ISC), already used by the XRP verifier. SS58
|
||
// checksum is blake2b-512 from golang.org/x/crypto (BSD-3). Zero copyleft.
|
||
//
|
||
// Refs:
|
||
// - https://github.com/paritytech/substrate/wiki/External-Address-Format-(SS58)
|
||
// - https://github.com/polkadot-js/common (u8aWrapBytes, signatureVerify)
|
||
// - https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-122.md
|
||
|
||
import (
|
||
"crypto/ed25519"
|
||
"crypto/subtle"
|
||
|
||
dcrecdsa "github.com/decred/dcrd/dcrec/secp256k1/v4/ecdsa"
|
||
"github.com/mr-tron/base58"
|
||
"github.com/oasisprotocol/curve25519-voi/primitives/sr25519"
|
||
"golang.org/x/crypto/blake2b"
|
||
)
|
||
|
||
// Substrate public-key lengths by scheme (bytes).
|
||
const (
|
||
substrateEdPubLen = 32 // sr25519, ed25519
|
||
substrateEcdsaPubLen = 33 // compressed secp256k1
|
||
ss58AccountLen = 32
|
||
ss58ChecksumLen = 2
|
||
ecdsaRecoverableSig = 65 // r(32) || s(32) || v(1)
|
||
)
|
||
|
||
// ss58Pre is the SS58 context prefix prepended to the checksum preimage: the
|
||
// ASCII bytes of "SS58PRE". Mirrors the TS SS58PRE.
|
||
var ss58Pre = []byte{0x53, 0x53, 0x35, 0x38, 0x50, 0x52, 0x45}
|
||
|
||
// substrateWrapPrefix / substrateWrapPostfix are the polkadot.js extension byte
|
||
// wrapper applied by signRaw({type:'bytes'}): "<Bytes>" + data + "</Bytes>".
|
||
// Mirrors @polkadot/util's U8A_WRAP_PREFIX / U8A_WRAP_POSTFIX.
|
||
var (
|
||
substrateWrapPrefix = []byte("<Bytes>")
|
||
substrateWrapPostfix = []byte("</Bytes>")
|
||
)
|
||
|
||
// substrateSigningContext is the Schnorrkel signing context Substrate uses
|
||
// (matches polkadot.js: signing_context(b"substrate")).
|
||
var substrateSigningContext = []byte("substrate")
|
||
|
||
// wrapBytes reproduces u8aWrapBytes for a CAIP-122 message: it wraps a message
|
||
// that is not already wrapped (our messages never are). Mirrors the TS
|
||
// u8aWrapBytes used by the verifier.
|
||
func wrapBytes(message string) []byte {
|
||
msg := []byte(message)
|
||
out := make([]byte, 0, len(substrateWrapPrefix)+len(msg)+len(substrateWrapPostfix))
|
||
out = append(out, substrateWrapPrefix...)
|
||
out = append(out, msg...)
|
||
out = append(out, substrateWrapPostfix...)
|
||
return out
|
||
}
|
||
|
||
// blake2b256 returns the 32-byte blake2b digest of data.
|
||
func blake2b256(data []byte) []byte {
|
||
sum := blake2b.Sum256(data)
|
||
return sum[:]
|
||
}
|
||
|
||
// blake2b512 returns the 64-byte blake2b digest of data.
|
||
func blake2b512(data []byte) []byte {
|
||
sum := blake2b.Sum512(data)
|
||
return sum[:]
|
||
}
|
||
|
||
// decodeSS58 decodes an SS58 address to its 32-byte AccountId, validating the
|
||
// SS58 checksum. Returns ok=false on any malformation (bad base58, unknown
|
||
// prefix length, wrong account length, bad checksum) — fail closed. Supports
|
||
// the 1-byte address-type prefixes (network ids 0–63) and the 2-byte form
|
||
// (64–16383), each with a 2-byte checksum. Mirrors the TS decodeSs58.
|
||
func decodeSS58(address string) ([]byte, bool) {
|
||
if len(address) == 0 || len(address) > maxAddressLen {
|
||
return nil, false
|
||
}
|
||
raw, err := base58.Decode(trimSpace(address))
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
if len(raw) == 0 {
|
||
return nil, false
|
||
}
|
||
var prefixLen int
|
||
if raw[0]&0b0100_0000 == 0 {
|
||
prefixLen = 1
|
||
} else {
|
||
prefixLen = 2
|
||
}
|
||
accountLen := len(raw) - prefixLen - ss58ChecksumLen
|
||
if accountLen != ss58AccountLen {
|
||
return nil, false
|
||
}
|
||
body := raw[:prefixLen+accountLen]
|
||
checksum := raw[prefixLen+accountLen:]
|
||
|
||
preimage := make([]byte, 0, len(ss58Pre)+len(body))
|
||
preimage = append(preimage, ss58Pre...)
|
||
preimage = append(preimage, body...)
|
||
full := blake2b512(preimage)
|
||
if subtle.ConstantTimeCompare(full[:ss58ChecksumLen], checksum) != 1 {
|
||
return nil, false
|
||
}
|
||
return raw[prefixLen : prefixLen+accountLen], true
|
||
}
|
||
|
||
// VerifyPolkadot verifies a Polkadot/Substrate login-message signature
|
||
// (sr25519 / ed25519 / ecdsa) with SS58 AccountId binding. Both the signature
|
||
// check and the address binding must pass. Fails closed on any decode error or
|
||
// scheme mismatch — never panics. Port of src/polkadot/verify.ts.
|
||
func VerifyPolkadot(proof Proof) bool {
|
||
// Bounded presence of every attacker-controlled string before any decode.
|
||
if !withinLen(proof.PublicKey, maxPubKeyLen) || !withinLen(proof.Signature, maxSignatureLen) {
|
||
return false
|
||
}
|
||
if len(proof.Message) > maxMessageLen || !withinLen(proof.Address, maxAddressLen) {
|
||
return false
|
||
}
|
||
publicKey, err := hexToBytes(proof.PublicKey)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
|
||
accountID, ok := decodeSS58(proof.Address)
|
||
if !ok {
|
||
return false
|
||
}
|
||
|
||
wrapped := wrapBytes(proof.Message)
|
||
sig, err := decodeSignature(proof.Signature)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
|
||
switch proof.Scheme {
|
||
case SchemeSr25519:
|
||
if len(publicKey) != substrateEdPubLen {
|
||
return false
|
||
}
|
||
// Binding: AccountId is the 32-byte public key.
|
||
if subtle.ConstantTimeCompare(accountID, publicKey) != 1 {
|
||
return false
|
||
}
|
||
pk, err := sr25519.NewPublicKeyFromBytes(publicKey)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
s, err := sr25519.NewSignatureFromBytes(sig)
|
||
if err != nil {
|
||
return false
|
||
}
|
||
ctx := sr25519.NewSigningContext(substrateSigningContext)
|
||
return pk.Verify(ctx.NewTranscriptBytes(wrapped), s)
|
||
|
||
case SchemeEd25519Substr:
|
||
if len(publicKey) != substrateEdPubLen {
|
||
return false
|
||
}
|
||
if subtle.ConstantTimeCompare(accountID, publicKey) != 1 {
|
||
return false
|
||
}
|
||
if len(sig) != ed25519.SignatureSize {
|
||
return false
|
||
}
|
||
return ed25519.Verify(ed25519.PublicKey(publicKey), wrapped, sig)
|
||
|
||
case SchemeEcdsaSubstr:
|
||
if len(publicKey) != substrateEcdsaPubLen {
|
||
return false
|
||
}
|
||
// Binding: AccountId is blake2b-256 of the 33-byte compressed key.
|
||
if subtle.ConstantTimeCompare(accountID, blake2b256(publicKey)) != 1 {
|
||
return false
|
||
}
|
||
// polkadot.js ecdsa signs blake2b-256(message) with a 65-byte
|
||
// recoverable signature (r||s||v). Recover the key and require it
|
||
// equals the declared public key.
|
||
if len(sig) != ecdsaRecoverableSig {
|
||
return false
|
||
}
|
||
digest := blake2b256(wrapped)
|
||
recovered, ok := recoverSecp256k1Compressed(sig, digest)
|
||
if !ok {
|
||
return false
|
||
}
|
||
return subtle.ConstantTimeCompare(recovered, publicKey) == 1
|
||
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// recoverSecp256k1Compressed recovers the compressed (33-byte) secp256k1 public
|
||
// key from a 65-byte recoverable signature (r||s||v) over digest. The recovery
|
||
// id v is the last byte (0/1, or 27/28 — both tolerated). Returns ok=false on
|
||
// any failure. Uses decred's ecdsa, which expects the recovery byte FIRST.
|
||
func recoverSecp256k1Compressed(sig65, digest []byte) ([]byte, bool) {
|
||
r := sig65[:32]
|
||
s := sig65[32:64]
|
||
v := sig65[64]
|
||
if v >= 27 {
|
||
v -= 27
|
||
}
|
||
if v > 3 {
|
||
return nil, false
|
||
}
|
||
// decred's RecoverCompact wants: [v+27] || r || s.
|
||
compact := make([]byte, 0, 65)
|
||
compact = append(compact, v+27)
|
||
compact = append(compact, r...)
|
||
compact = append(compact, s...)
|
||
pub, _, err := dcrecdsa.RecoverCompact(compact, digest)
|
||
if err != nil {
|
||
return nil, false
|
||
}
|
||
return pub.SerializeCompressed(), true
|
||
}
|
||
|
||
// trimSpace is a tiny local helper to avoid importing strings solely for one
|
||
// call site; mirrors strings.TrimSpace for ASCII whitespace.
|
||
func trimSpace(s string) string {
|
||
start := 0
|
||
for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
|
||
start++
|
||
}
|
||
end := len(s)
|
||
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
|
||
end--
|
||
}
|
||
return s[start:end]
|
||
}
|