Files
zeekay 0403d39c62 harden(verify): defense-in-depth fail-closed across all 7 SIWx chains
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.
2026-06-24 16:03:51 -07:00

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}
}