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.
237 lines
8.0 KiB
Go
237 lines
8.0 KiB
Go
package walletconnect
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// SignatureScheme is the signing scheme that produced a proof. The verifier
|
|
// dispatches on this, not on Chain. Mirrors the TS SignatureScheme union.
|
|
type SignatureScheme string
|
|
|
|
const (
|
|
SchemeSecp256k1EIP191 SignatureScheme = "secp256k1-eip191" // EVM personal_sign (EIP-191)
|
|
SchemeEd25519 SignatureScheme = "ed25519" // Solana, TON
|
|
SchemeBIP322 SignatureScheme = "bip322" // Bitcoin message signing (BIP-322)
|
|
SchemeTonProof SignatureScheme = "ton-proof" // TON Connect ton_proof (ed25519 inside)
|
|
SchemeSecp256k1XRPL SignatureScheme = "secp256k1-xrpl" // XRPL signMessage (secp256k1)
|
|
SchemeEd25519XRPL SignatureScheme = "ed25519-xrpl" // XRPL ed25519 keypair
|
|
SchemeSr25519 SignatureScheme = "sr25519" // Polkadot/Substrate default (Schnorrkel/Ristretto255)
|
|
SchemeEd25519Substr SignatureScheme = "ed25519-substrate" // Polkadot/Substrate ed25519 accounts
|
|
SchemeEcdsaSubstr SignatureScheme = "ecdsa-substrate" // Polkadot/Substrate ecdsa (secp256k1) accounts
|
|
SchemeEd25519Cardano SignatureScheme = "ed25519-cardano" // Cardano CIP-8/CIP-30 signData (ed25519 in COSE_Sign1)
|
|
)
|
|
|
|
// Proof is what a wallet hands back after signing — everything the server needs
|
|
// to verify. Mirrors the TS SignedProof.
|
|
type Proof struct {
|
|
Chain Chain
|
|
Scheme SignatureScheme
|
|
Address string
|
|
PublicKey string
|
|
Message string
|
|
Signature string
|
|
Extra map[string]any
|
|
}
|
|
|
|
// Expectation holds the server-side expectations checked against the parsed
|
|
// message during verify. Mirrors the TS VerifyExpectation, but expressed with
|
|
// Go-native time types: Now overrides "now" (zero value => time.Now()) and
|
|
// ClockSkew is the tolerated skew (zero value => DefaultClockSkew).
|
|
type Expectation struct {
|
|
Domain string
|
|
Nonce string
|
|
Address string // optional; require an exact address (case-insensitive for EVM)
|
|
Now time.Time
|
|
ClockSkew time.Duration
|
|
}
|
|
|
|
// DefaultClockSkew matches the TS DEFAULT_SKEW_MS (5 minutes).
|
|
const DefaultClockSkew = 5 * time.Minute
|
|
|
|
// Reason is a machine-readable failure reason. The string values match the TS
|
|
// VerifyResult.reason union exactly.
|
|
type Reason string
|
|
|
|
const (
|
|
ReasonBadSignature Reason = "bad-signature"
|
|
ReasonAddressMismatch Reason = "address-mismatch"
|
|
ReasonDomainMismatch Reason = "domain-mismatch"
|
|
ReasonNonceMismatch Reason = "nonce-mismatch"
|
|
ReasonExpired Reason = "expired"
|
|
ReasonNotYetValid Reason = "not-yet-valid"
|
|
ReasonMalformedMessage Reason = "malformed-message"
|
|
ReasonUnsupportedScheme Reason = "unsupported-scheme"
|
|
ReasonMissingPublicKey Reason = "missing-public-key"
|
|
)
|
|
|
|
// Result mirrors the TS VerifyResult. Reason is empty when OK is true.
|
|
type Result struct {
|
|
OK bool
|
|
Reason Reason
|
|
Address string
|
|
Chain Chain
|
|
}
|
|
|
|
func fail(r Reason) Result { return Result{OK: false, Reason: r} }
|
|
|
|
// gate is the structural + size + chain↔scheme check, run before any parse or
|
|
// crypto. Rejects a proof whose attacker-controlled fields are oversized (DoS)
|
|
// or whose (chain, scheme) pair is illegitimate (cross-chain scheme confusion).
|
|
// Returns (failing Result, true) when the proof must be rejected, or
|
|
// (_, false) when it is shaped well enough to proceed. PublicKey is optional per
|
|
// scheme, so it is bounded only when present. Mirrors the TS gate().
|
|
func gate(proof Proof) (Result, bool) {
|
|
if !withinLen(proof.Message, maxMessageLen) {
|
|
return fail(ReasonMalformedMessage), true
|
|
}
|
|
if !withinLen(proof.Signature, maxSignatureLen) {
|
|
return fail(ReasonBadSignature), true
|
|
}
|
|
if !withinLen(proof.Address, maxAddressLen) {
|
|
return fail(ReasonAddressMismatch), true
|
|
}
|
|
if proof.PublicKey != "" && len(proof.PublicKey) > maxPubKeyLen {
|
|
return fail(ReasonMissingPublicKey), true
|
|
}
|
|
if !chainAllowsScheme(proof.Chain, proof.Scheme) {
|
|
return fail(ReasonUnsupportedScheme), true
|
|
}
|
|
return Result{}, false
|
|
}
|
|
|
|
// addressesEqual is case-insensitive only for EVM (checksummed hex); all other
|
|
// chains compare exactly. Mirrors the TS addressesEqual.
|
|
func addressesEqual(chain Chain, a, b string) bool {
|
|
x := strings.TrimSpace(a)
|
|
y := strings.TrimSpace(b)
|
|
if chain == ChainEVM {
|
|
return strings.EqualFold(x, y)
|
|
}
|
|
return x == y
|
|
}
|
|
|
|
// parseTime parses an ISO-8601 timestamp the way JS Date.parse does for the
|
|
// values newChallenge emits (RFC3339 with milliseconds and a 'Z' zone). It
|
|
// tolerates the RFC3339 variants Date.parse accepts. ok=false means the field
|
|
// was absent or unparseable — both treated as "no constraint", matching the TS
|
|
// `parseTime` returning null.
|
|
func parseTime(s *string) (time.Time, bool) {
|
|
if s == nil {
|
|
return time.Time{}, false
|
|
}
|
|
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
|
|
if t, err := time.Parse(layout, *s); err == nil {
|
|
return t, true
|
|
}
|
|
}
|
|
return time.Time{}, false
|
|
}
|
|
|
|
// crypto dispatches to the per-chain cryptographic verifier. The (ok, supported)
|
|
// pair mirrors the TS `boolean | null`: supported=false maps to TS null
|
|
// (unsupported scheme); supported=true with ok=false maps to TS false.
|
|
func crypto(p Proof) (ok, supported bool) {
|
|
switch p.Scheme {
|
|
case SchemeSecp256k1EIP191:
|
|
return VerifyEVM(p.Message, p.Signature, p.Address), true
|
|
case SchemeEd25519:
|
|
// ed25519-over-message is Solana today; TON uses 'ton-proof'.
|
|
return VerifySolana(p.Message, p.Signature, p.Address), true
|
|
case SchemeTonProof:
|
|
return VerifyTon(p), true
|
|
case SchemeBIP322:
|
|
return VerifyBitcoin(p), true
|
|
case SchemeSecp256k1XRPL, SchemeEd25519XRPL:
|
|
return VerifyXrp(p), true
|
|
case SchemeSr25519, SchemeEd25519Substr, SchemeEcdsaSubstr:
|
|
return VerifyPolkadot(p), true
|
|
case SchemeEd25519Cardano:
|
|
return VerifyCardano(p), true
|
|
default:
|
|
return false, false
|
|
}
|
|
}
|
|
|
|
// VerifyProof is the one server-side entry point. Chain-agnostic: parse the
|
|
// CAIP-122 message, enforce address/domain/nonce/time, then dispatch to the
|
|
// per-chain cryptographic verifier. Fails closed: any unknown scheme or
|
|
// malformed input returns {OK: false, Reason}, never panics. Mirrors the TS
|
|
// verifyProof exactly, including check order and reasons.
|
|
func VerifyProof(proof Proof, expected Expectation) (result Result) {
|
|
// Absolute backstop: a per-chain verifier or parser that panics despite
|
|
// being written panic-free must never escape as a panic — that is not
|
|
// fail-closed. Any unexpected panic collapses to a rejected proof. Mirrors
|
|
// the TS try/catch wrapping verifyProof.
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
result = fail(ReasonBadSignature)
|
|
}
|
|
}()
|
|
|
|
// Structural + size + chain↔scheme gate before any parse or crypto.
|
|
if bad, rejected := gate(proof); rejected {
|
|
return bad
|
|
}
|
|
|
|
parsed, err := ParseSiwxMessage(proof.Message)
|
|
if err != nil {
|
|
return fail(ReasonMalformedMessage)
|
|
}
|
|
|
|
// 1. Binding: the signer in the message must match the proof's address.
|
|
if !addressesEqual(proof.Chain, parsed.Address, proof.Address) {
|
|
return fail(ReasonAddressMismatch)
|
|
}
|
|
if expected.Address != "" && !addressesEqual(proof.Chain, proof.Address, expected.Address) {
|
|
return fail(ReasonAddressMismatch)
|
|
}
|
|
|
|
// 2. Domain + nonce binding (anti-phishing, anti-replay).
|
|
if parsed.Domain != expected.Domain {
|
|
return fail(ReasonDomainMismatch)
|
|
}
|
|
if parsed.Nonce != expected.Nonce {
|
|
return fail(ReasonNonceMismatch)
|
|
}
|
|
|
|
// 3. Time window.
|
|
now := expected.Now
|
|
if now.IsZero() {
|
|
now = time.Now()
|
|
}
|
|
skew := expected.ClockSkew
|
|
if skew == 0 {
|
|
skew = DefaultClockSkew
|
|
}
|
|
if exp, ok := parseTime(parsed.ExpirationTime); ok {
|
|
// now > exp + skew
|
|
if now.After(exp.Add(skew)) {
|
|
return fail(ReasonExpired)
|
|
}
|
|
}
|
|
if nbf, ok := parseTime(parsed.NotBefore); ok {
|
|
// now + skew < nbf
|
|
if now.Add(skew).Before(nbf) {
|
|
return fail(ReasonNotYetValid)
|
|
}
|
|
}
|
|
if iat, ok := parseTime(&parsed.IssuedAt); ok {
|
|
// iat - skew > now
|
|
if iat.Add(-skew).After(now) {
|
|
return fail(ReasonNotYetValid)
|
|
}
|
|
}
|
|
|
|
// 4. Cryptographic signature.
|
|
ok, supported := crypto(proof)
|
|
if !supported {
|
|
return fail(ReasonUnsupportedScheme)
|
|
}
|
|
if !ok {
|
|
return fail(ReasonBadSignature)
|
|
}
|
|
|
|
return Result{OK: true, Address: proof.Address, Chain: proof.Chain}
|
|
}
|