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

263 lines
7.4 KiB
Go

package walletconnect
// Cardano verifier — CIP-8 / CIP-30 signData (COSE_Sign1 + COSE_Key, ed25519).
//
// Port of src/cardano/verify.ts, byte-for-byte. A CIP-30 wallet's
// api.signData(addrHex, payloadHex) returns { signature: cbor<COSE_Sign1>,
// key: cbor<COSE_Key> }; the connector packs them into a Proof:
//
// Scheme "ed25519-cardano"
// Address bech32 address (addr1…/stake1…)
// PublicKey COSE_Key ed25519 public key (hex; 32B raw or 64B extended)
// Signature COSE_Sign1 cbor (hex)
// Message CAIP-122 string (== the COSE_Sign1 payload, non-hashed)
// Extra["coseKey"] COSE_Key cbor (hex) [optional; PublicKey is authoritative]
//
// Two independent checks, both must hold (decomplected, like Polkadot):
//
// 1. Signature: ed25519 over the COSE Sig_structure
// [ "Signature1", protected, external_aad(empty), payload ] reconstructed
// from the COSE_Sign1 CBOR. The embedded payload MUST equal the proof's
// CAIP-122 message bytes (non-hashed signing).
// 2. Address binding: blake2b-224(pubkey[:32]) == the 28-byte key credential
// embedded in the bech32 address (payment credential for addr1…, stake
// credential for stake1…). The bech32 checksum must be valid.
//
// Pure: no I/O, no clock. Fails closed — every error path returns false,
// nothing panics. ed25519 is stdlib; blake2b is golang.org/x/crypto (BSD-3);
// CBOR/bech32 are inline. Zero copyleft.
//
// Refs:
// - CIP-8 https://cips.cardano.org/cip/CIP-0008
// - CIP-30 https://cips.cardano.org/cip/CIP-0030
// - CIP-19 https://cips.cardano.org/cip/CIP-0019
// - RFC 9052 https://datatracker.ietf.org/doc/rfc9052/
import (
"crypto/ed25519"
"crypto/subtle"
"golang.org/x/crypto/blake2b"
)
const (
cardanoPubKeyLen = 32 // ed25519 public key
cardanoSigLen = 64 // ed25519 signature
cardanoKeyHashLen = 28 // blake2b-224 key credential
coseKeyXLabel = -2 // COSE_Key map key for the public key (x), RFC 9052
)
// blake2b224 returns the 28-byte blake2b digest of data — the Cardano
// key-credential function.
func blake2b224(data []byte) []byte {
h, _ := blake2b.New(cardanoKeyHashLen, nil) // err only on bad size/key; 28/nil is valid
h.Write(data)
return h.Sum(nil)
}
// cardanoKeyCredential extracts the 28-byte key credential that must equal
// blake2b-224(pubkey) from a decoded Cardano address. Returns ok=false for
// address types this login flow does not bind (script credentials, pointer,
// Byron, malformed). Mirrors the TS keyCredentialFromAddress.
func cardanoKeyCredential(hrp string, data []byte) ([]byte, bool) {
if len(data) < 1+cardanoKeyHashLen {
return nil, false
}
header := data[0]
typ := header >> 4
switch hrp {
case "addr", "addr_test":
switch typ {
// Base: payment=KEY binds an ed25519 key, i.e. type 0 or 2.
case 0, 2:
if len(data) < 1+2*cardanoKeyHashLen {
return nil, false
}
return data[1 : 1+cardanoKeyHashLen], true
// Enterprise key: type 6.
case 6:
if len(data) != 1+cardanoKeyHashLen {
return nil, false
}
return data[1 : 1+cardanoKeyHashLen], true
default:
return nil, false
}
case "stake", "stake_test":
// type 14 = stake key-hash; 15 = script-hash.
if typ == 14 {
if len(data) != 1+cardanoKeyHashLen {
return nil, false
}
return data[1 : 1+cardanoKeyHashLen], true
}
return nil, false
default:
return nil, false
}
}
// cardanoPublicKeyFromCoseKey reads the ed25519 public key (COSE_Key map key
// -2) from a hex COSE_Key. Returns ok=false if absent or not bytes.
func cardanoPublicKeyFromCoseKey(coseKeyHex string) ([]byte, bool) {
raw, err := hexToBytes(coseKeyHex)
if err != nil {
return nil, false
}
decoded, ok := cborDecode(raw)
if !ok {
return nil, false
}
m, ok := decoded.(*cborMap)
if !ok {
return nil, false
}
x, ok := m.getInt(coseKeyXLabel)
if !ok {
return nil, false
}
b, ok := x.([]byte)
if !ok {
return nil, false
}
return b, true
}
// cardanoCoseSign1 narrows a CBOR value to a definite COSE_Sign1 quad.
type cardanoCoseSign1 struct {
protectedSer []byte
unprotected *cborMap
payload []byte // nil means CBOR null
hasPayload bool
signature []byte
}
func cardanoAsCoseSign1(v cborValue, ok bool) (cardanoCoseSign1, bool) {
var out cardanoCoseSign1
if !ok {
return out, false
}
arr, isArr := v.([]cborValue)
if !isArr || len(arr) != 4 {
return out, false
}
p, okP := arr[0].([]byte)
if !okP {
return out, false
}
u, okU := arr[1].(*cborMap)
if !okU {
return out, false
}
sig, okS := arr[3].([]byte)
if !okS {
return out, false
}
out.protectedSer = p
out.unprotected = u
out.signature = sig
switch pay := arr[2].(type) {
case []byte:
out.payload = pay
out.hasPayload = true
case nil:
out.payload = nil
out.hasPayload = false
default:
return out, false
}
return out, true
}
// VerifyCardano verifies a Cardano CIP-8/CIP-30 login proof (ed25519 over
// COSE_Sign1) with blake2b-224 address binding. Both the signature check and the
// address binding must pass. Fails closed on any decode error, length mismatch,
// hashed payload, or scheme mismatch — never panics. Port of
// src/cardano/verify.ts verifyCardano.
func VerifyCardano(proof Proof) bool {
// Scheme + bounded presence of every attacker-controlled string. Bounds cap
// the COSE/CBOR decode + hashing and make this exported verifier safe when
// called directly (the dispatcher also gates).
if proof.Scheme != SchemeEd25519Cardano {
return false
}
if !withinLen(proof.PublicKey, maxPubKeyLen) || !withinLen(proof.Signature, maxSignatureLen) ||
!withinLen(proof.Message, maxMessageLen) || !withinLen(proof.Address, maxAddressLen) {
return false
}
fullPub, err := hexToBytes(proof.PublicKey)
if err != nil {
return false
}
if len(fullPub) != cardanoPubKeyLen && len(fullPub) != 2*cardanoPubKeyLen {
return false
}
pub := fullPub[:cardanoPubKeyLen]
// Defence in depth: when a COSE_Key is carried, its -2 must equal PublicKey.
if proof.Extra != nil {
if ck, isStr := proof.Extra["coseKey"].(string); isStr && len(ck) > 0 {
if len(ck) > maxExtraStringLen {
return false
}
fromKey, ok := cardanoPublicKeyFromCoseKey(ck)
if !ok {
return false
}
if len(fromKey) < cardanoPubKeyLen {
return false
}
if subtle.ConstantTimeCompare(fromKey[:cardanoPubKeyLen], pub) != 1 {
return false
}
}
}
// --- 1. Address binding: blake2b-224(pubkey) == key credential. ---
hrp, addrData, ok := cardanoBech32Decode(trimSpace(proof.Address))
if !ok {
return false
}
credential, ok := cardanoKeyCredential(hrp, addrData)
if !ok {
return false
}
if subtle.ConstantTimeCompare(blake2b224(pub), credential) != 1 {
return false
}
// --- 2. Signature over the reconstructed COSE Sig_structure. ---
sigBytes, err := hexToBytes(proof.Signature)
if err != nil {
return false
}
cose, ok := cardanoAsCoseSign1(cborDecode(sigBytes))
if !ok {
return false
}
// Reject hashed payloads: our login flow signs the message verbatim.
if hashed, has := cose.unprotected.getStr("hashed"); has {
if b, isBool := hashed.(bool); isBool && b {
return false
}
}
// Bind the signed payload to the CAIP-122 message.
if !cose.hasPayload {
return false
}
if subtle.ConstantTimeCompare(cose.payload, []byte(proof.Message)) != 1 {
return false
}
if len(cose.signature) != cardanoSigLen {
return false
}
sigStruct := cborBuildSigStructure(cose.protectedSer, []byte{}, cose.payload)
return ed25519.Verify(ed25519.PublicKey(pub), sigStruct, cose.signature)
}