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
7.2 KiB
Go

// Package walletconnect is the Go port of @luxwallet/connect's login verifier.
//
// It mirrors the TypeScript src/ byte-for-byte so Hanzo IAM (Casdoor, Go)
// verifies wallet logins identically to verifyProof: same CAIP-122 message
// parse, same domain/nonce/time checks, same EIP-191 digest, same ed25519
// check. The two implementations must stay in lockstep.
package walletconnect
import (
"fmt"
"regexp"
"strings"
)
// Chain is a supported chain family. Values, not places — namespaced by this
// type. Mirrors the TS `Chain` union.
type Chain string
const (
ChainEVM Chain = "evm"
ChainSolana Chain = "solana"
ChainBitcoin Chain = "bitcoin"
ChainTON Chain = "ton"
ChainXRP Chain = "xrp"
ChainPolkadot Chain = "polkadot"
ChainCardano Chain = "cardano"
)
// chainLabel is the human chain label used on the first line of the message.
// Mirrors the TS CHAIN_LABEL record exactly.
var chainLabel = map[Chain]string{
ChainEVM: "Ethereum",
ChainSolana: "Solana",
ChainBitcoin: "Bitcoin",
ChainTON: "TON",
ChainXRP: "XRP Ledger",
ChainPolkadot: "Polkadot",
ChainCardano: "Cardano",
}
// ParsedSiwx is the result of parsing a CAIP-122 message. Optional fields use
// pointers so absence (TS `undefined`) is distinguishable from empty string.
type ParsedSiwx struct {
Domain string
Address string
Statement *string
URI string
Version *string
ChainID *string
Nonce string
IssuedAt string
ExpirationTime *string
NotBefore *string
RequestID *string
Resources []string
}
// LoginChallenge mirrors the TS LoginChallenge: the fields a server asks a
// wallet to sign. Optional fields are pointers (TS `undefined`).
type LoginChallenge struct {
Domain string
URI string
Statement *string
Nonce string
IssuedAt string
ExpirationTime *string
NotBefore *string
RequestID *string
Version *string
Resources []string
}
// BuildParams mirrors the TS BuildParams for buildSiwxMessage.
type BuildParams struct {
Challenge LoginChallenge
Address string
Chain Chain
// ChainID is the CAIP-2 network id, e.g. "eip155:1". nil to omit.
ChainID *string
}
// BuildSiwxMessage renders a LoginChallenge to the canonical CAIP-122 message
// string. Mirrors the TS buildSiwxMessage line-for-line. Returns an error if a
// statement contains a newline (TS throws).
func BuildSiwxMessage(p BuildParams) (string, error) {
c := p.Challenge
label := chainLabel[p.Chain]
lines := make([]string, 0, 16)
lines = append(lines, fmt.Sprintf("%s wants you to sign in with your %s account:", c.Domain, label))
lines = append(lines, p.Address)
lines = append(lines, "")
// Statement block is optional. When present it sits on its own line between
// two blank lines (per EIP-4361 ABNF).
if c.Statement != nil && len(*c.Statement) > 0 {
if strings.Contains(*c.Statement, "\n") {
return "", fmt.Errorf("caip122: statement must be a single line")
}
lines = append(lines, *c.Statement)
lines = append(lines, "")
}
lines = append(lines, "URI: "+c.URI)
version := "1"
if c.Version != nil {
version = *c.Version
}
lines = append(lines, "Version: "+version)
if p.ChainID != nil {
lines = append(lines, "Chain ID: "+*p.ChainID)
}
lines = append(lines, "Nonce: "+c.Nonce)
lines = append(lines, "Issued At: "+c.IssuedAt)
if c.ExpirationTime != nil {
lines = append(lines, "Expiration Time: "+*c.ExpirationTime)
}
if c.NotBefore != nil {
lines = append(lines, "Not Before: "+*c.NotBefore)
}
if c.RequestID != nil {
lines = append(lines, "Request ID: "+*c.RequestID)
}
if len(c.Resources) > 0 {
lines = append(lines, "Resources:")
for _, r := range c.Resources {
lines = append(lines, "- "+r)
}
}
return strings.Join(lines, "\n"), nil
}
// headerRE mirrors the TS HEADER_RE. The domain is non-greedy (`+?`) so it
// stops at the first " wants you to sign in with your " occurrence. Go's RE2
// has no lazy/greedy backtracking difference that matters here because the
// suffix is anchored with `$`, but we keep `+?` for fidelity with the TS source.
var headerRE = regexp.MustCompile(`^(.+?) wants you to sign in with your .+ account:$`)
// fieldRE mirrors the TS FIELD_RE: an exact, ordered set of recognised keys.
var fieldRE = regexp.MustCompile(`^(URI|Version|Chain ID|Nonce|Issued At|Expiration Time|Not Before|Request ID): (.*)$`)
func strPtr(s string) *string { return &s }
// ParseSiwxMessage parses a CAIP-122 message back into its fields. Returns an
// error on malformed input. Mirrors the TS parseSiwxMessage exactly, including
// the statement/resources block handling and the required-field check.
func ParseSiwxMessage(message string) (ParsedSiwx, error) {
var out ParsedSiwx
// Bound the work before splitting: an unbounded message (or one that is all
// newlines) would otherwise force a huge slice + per-line loop. Mirrors the
// TS parseSiwxMessage guards.
if len(message) == 0 {
return out, fmt.Errorf("caip122: empty message")
}
if len(message) > maxMessageLen {
return out, fmt.Errorf("caip122: message too large")
}
raw := strings.Split(message, "\n")
if len(raw) < 2 {
return out, fmt.Errorf("caip122: message too short")
}
if len(raw) > maxMessageLines {
return out, fmt.Errorf("caip122: too many lines")
}
header := headerRE.FindStringSubmatch(raw[0])
if header == nil {
return out, fmt.Errorf("caip122: malformed header line")
}
out.Domain = header[1]
out.Address = strings.TrimSpace(raw[1])
if len(out.Address) == 0 {
return out, fmt.Errorf("caip122: missing address line")
}
// Everything from line 2 onward: an optional statement block, then fields.
var resources []string
inResources := false
var statementParts []string
sawField := false
for i := 2; i < len(raw); i++ {
line := raw[i]
if inResources {
if strings.HasPrefix(line, "- ") {
resources = append(resources, line[2:])
continue
}
inResources = false
}
if line == "Resources:" {
inResources = true
sawField = true
continue
}
if f := fieldRE.FindStringSubmatch(line); f != nil {
sawField = true
key, v := f[1], f[2]
switch key {
case "URI":
out.URI = v
case "Version":
out.Version = strPtr(v)
case "Chain ID":
out.ChainID = strPtr(v)
case "Nonce":
out.Nonce = v
case "Issued At":
out.IssuedAt = v
case "Expiration Time":
out.ExpirationTime = strPtr(v)
case "Not Before":
out.NotBefore = strPtr(v)
case "Request ID":
out.RequestID = strPtr(v)
}
continue
}
// Pre-field, non-empty, non-field lines are the statement.
if !sawField && len(line) > 0 {
statementParts = append(statementParts, line)
}
}
if len(statementParts) > 0 {
out.Statement = strPtr(strings.Join(statementParts, "\n"))
}
if len(resources) > 0 {
out.Resources = resources
}
// TS checks: out.uri == null || out.nonce == null || out.issuedAt == null.
// A field that never appeared is the zero value "" here; the TS message
// always renders these three, and parse must reject a message missing any.
if out.URI == "" || out.Nonce == "" || out.IssuedAt == "" {
return out, fmt.Errorf("caip122: missing required field (URI / Nonce / Issued At)")
}
return out, nil
}