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

404 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package walletconnect
// Mirrors src/__tests__/cardano.test.ts. Two kinds of coverage:
//
// 1. Cross-language KAT vectors: COSE_Sign1 + COSE_Key produced by the TS side
// (@noble ed25519 + the TS CBOR encoder, the exact bytes a CIP-30 wallet
// emits) over the EXACT CAIP-122 message, which Go MUST verify byte-for-byte.
// If the Go verifier ever drifts from the TS verifier, these fail loudly.
// 2. In-Go round-trips (ed25519 stdlib, blake2b-224, the Go CBOR encoder) plus
// fail-closed hardening.
//
// ed25519 is stdlib; blake2b is golang.org/x/crypto (BSD-3); CBOR/bech32 are
// inline. Zero copyleft.
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"testing"
)
// --- Cross-language KAT vectors (generated by the TS @noble + CBOR encoder). ---
// Regenerate with: VITEST_PRINT_KAT=1 pnpm test cardano (see the TS test).
type cardanoKAT struct {
address string
publicKey string
message string
signature string
coseKey string
nonce string
}
var (
katCardanoBase = cardanoKAT{
address: "addr1qydtm4de8ucmavj80hy8sxt2mpj7qw2l0543c7ggzanuz8a5ajfxslft78jgx809l7nzjdu8fyslhhfhwk8j4kjxz4dq67y5xx",
publicKey: "35e4c4b958584fa2616909742940edbb8e9b07c071be016311d69c3b91393ce5",
message: "hanzo.id wants you to sign in with your Cardano account:\naddr1qydtm4de8ucmavj80hy8sxt2mpj7qw2l0543c7ggzanuz8a5ajfxslft78jgx809l7nzjdu8fyslhhfhwk8j4kjxz4dq67y5xx\n\nURI: https://hanzo.id/login\nVersion: 1\nNonce: adaBase00001\nIssued At: 2023-11-14T22:13:20.000Z\nExpiration Time: 2023-11-14T22:23:20.000Z",
signature: "845846a2012767616464726573735839011abdd5b93f31beb2477dc878196ad865e0395f7d2b1c79081767c11fb4ec92687d2bf1e4831de5ffa62937874921fbdd37758f2ada46155aa166686173686564f459012a68616e7a6f2e69642077616e747320796f7520746f207369676e20696e207769746820796f75722043617264616e6f206163636f756e743a0a6164647231717964746d3464653875636d61766a3830687938737874326d706a377177326c30353433633767677a616e757a386135616a6678736c667437386a67783830396c376e7a6a6475386679736c68686668776b386a346b6a787a3464713637793578780a0a5552493a2068747470733a2f2f68616e7a6f2e69642f6c6f67696e0a56657273696f6e3a20310a4e6f6e63653a206164614261736530303030310a4973737565642041743a20323032332d31312d31345432323a31333a32302e3030305a0a45787069726174696f6e2054696d653a20323032332d31312d31345432323a32333a32302e3030305a58403ad2eced84b8b9be43ea9d8773e7b27d0efd99d4d2af87bcbf582cec4b2d5ae16b4117af06ffcd59686d45d6c8fc237dde1d1b6b54a68b8e9ec7356ff78f1703",
coseKey: "a401010327200621582035e4c4b958584fa2616909742940edbb8e9b07c071be016311d69c3b91393ce5",
nonce: "adaBase00001",
}
katCardanoReward = cardanoKAT{
address: "stake1uxvg2cd4zsemper69089e20jv9ugw6d4tt63wl6mv5q4yacutr3ns",
publicKey: "dd2922fcab6e49de023f5509701ddf992cf4ffa7ff596b5de481f999704b0124",
message: "hanzo.id wants you to sign in with your Cardano account:\nstake1uxvg2cd4zsemper69089e20jv9ugw6d4tt63wl6mv5q4yacutr3ns\n\nURI: https://hanzo.id/login\nVersion: 1\nNonce: adaStake0001\nIssued At: 2023-11-14T22:13:20.000Z\nExpiration Time: 2023-11-14T22:23:20.000Z",
signature: "84582aa201276761646472657373581de1988561b51433b0e47a2bce5ca9f261788769b55af5177f5b65015277a166686173686564f458fe68616e7a6f2e69642077616e747320796f7520746f207369676e20696e207769746820796f75722043617264616e6f206163636f756e743a0a7374616b653175787667326364347a73656d70657236393038396532306a763975677736643474743633776c366d76357134796163757472336e730a0a5552493a2068747470733a2f2f68616e7a6f2e69642f6c6f67696e0a56657273696f6e3a20310a4e6f6e63653a206164615374616b65303030310a4973737565642041743a20323032332d31312d31345432323a31333a32302e3030305a0a45787069726174696f6e2054696d653a20323032332d31312d31345432323a32333a32302e3030305a58403a0748ce83d9985c3a9b7d1f12b355d77d05063928fb02d532477d6dbaca6b752291e4a28065007b1946c848a0b2cf02087573142ee936313bfed89879433f0e",
coseKey: "a4010103272006215820dd2922fcab6e49de023f5509701ddf992cf4ffa7ff596b5de481f999704b0124",
nonce: "adaStake0001",
}
)
func (k cardanoKAT) proof() Proof {
return Proof{
Chain: ChainCardano,
Scheme: SchemeEd25519Cardano,
Address: k.address,
PublicKey: k.publicKey,
Message: k.message,
Signature: k.signature,
Extra: map[string]any{"coseKey": k.coseKey},
}
}
func TestCardanoKAT_CrossLanguage(t *testing.T) {
for _, k := range []cardanoKAT{katCardanoBase, katCardanoReward} {
k := k
t.Run(k.nonce, func(t *testing.T) {
if !VerifyCardano(k.proof()) {
t.Fatalf("VerifyCardano(%s KAT) = false, want true (TS↔Go drift)", k.nonce)
}
})
}
}
func TestCardanoKAT_RejectsTamperedMessage(t *testing.T) {
for _, k := range []cardanoKAT{katCardanoBase, katCardanoReward} {
k := k
t.Run(k.nonce, func(t *testing.T) {
p := k.proof()
p.Message += " "
if VerifyCardano(p) {
t.Fatalf("VerifyCardano(%s tampered message) = true, want false", k.nonce)
}
})
}
}
func TestCardanoKAT_RejectsFlippedSignatureBit(t *testing.T) {
for _, k := range []cardanoKAT{katCardanoBase, katCardanoReward} {
k := k
t.Run(k.nonce, func(t *testing.T) {
p := k.proof()
sig := mustHex(t, p.Signature)
sig[len(sig)-1] ^= 0x01 // flip a bit inside the ed25519 signature
p.Signature = hex.EncodeToString(sig)
if VerifyCardano(p) {
t.Fatalf("VerifyCardano(%s flipped sig) = true, want false", k.nonce)
}
})
}
}
func TestCardanoKAT_RejectsWrongAddress(t *testing.T) {
// base proof with the reward address (valid bech32, wrong credential).
p := katCardanoBase.proof()
p.Address = katCardanoReward.address
if VerifyCardano(p) {
t.Fatal("VerifyCardano(wrong address) = true, want false")
}
}
func TestCardanoKAT_RejectsMismatchedPublicKey(t *testing.T) {
p := katCardanoBase.proof()
p.PublicKey = katCardanoReward.publicKey
if VerifyCardano(p) {
t.Fatal("VerifyCardano(mismatched pubkey) = true, want false")
}
}
func TestCardanoKAT_RejectsCorruptedBech32Checksum(t *testing.T) {
p := katCardanoBase.proof()
a := []byte(p.Address)
if a[len(a)-1] == 'q' {
a[len(a)-1] = 'p'
} else {
a[len(a)-1] = 'q'
}
p.Address = string(a)
if VerifyCardano(p) {
t.Fatal("VerifyCardano(corrupted bech32 checksum) = true, want false")
}
}
func TestCardanoKAT_RejectsMismatchedCoseKey(t *testing.T) {
p := katCardanoBase.proof()
p.Extra = map[string]any{"coseKey": katCardanoReward.coseKey}
if VerifyCardano(p) {
t.Fatal("VerifyCardano(coseKey disagrees with publicKey) = true, want false")
}
}
func TestCardanoKAT_VerifiesWithoutCoseKeyExtra(t *testing.T) {
p := katCardanoBase.proof()
p.Extra = nil // publicKey is authoritative
if !VerifyCardano(p) {
t.Fatal("VerifyCardano(no coseKey extra) = false, want true")
}
}
// --- In-Go round-trip (independent of the TS-minted KATs). ---
// cardanoBaseAddressInGo builds a mainnet base address (type 0): header ||
// paymentHash(28) || stakeHash(28), encoded with the inline bech32 ENCODER from
// cardano_test (sign side) so drift surfaces as a failing round-trip.
func cardanoBaseAddressInGo(t *testing.T, pub []byte) (raw []byte, bech string) {
t.Helper()
header := byte((0 << 4) | 1) // type 0 base, network 1 mainnet
stakeHash := blake2b224([]byte("stake-" + hex.EncodeToString(pub)))
raw = make([]byte, 0, 1+28+28)
raw = append(raw, header)
raw = append(raw, blake2b224(pub)...)
raw = append(raw, stakeHash...)
bech = cardanoBech32EncodeTest(t, "addr", raw)
return raw, bech
}
func cardanoMessageInGo(t *testing.T, bech string) string {
t.Helper()
msg, err := BuildSiwxMessage(BuildParams{
Challenge: LoginChallenge{
Domain: "hanzo.id",
URI: "https://hanzo.id/login",
Nonce: "0123456789abcdef0123456789abcdef",
IssuedAt: "2026-01-01T00:00:00.000Z",
},
Address: bech,
Chain: ChainCardano,
})
if err != nil {
t.Fatalf("BuildSiwxMessage: %v", err)
}
return msg
}
// cardanoBech32EncodeTest is a tiny bech32 ENCODER for the test (the verifier
// only decodes). Mirrors the TS bech32Encode.
func cardanoBech32EncodeTest(t *testing.T, hrp string, data []byte) string {
t.Helper()
// 8 -> 5 bit, pad=true
var acc uint32
var bits int
data5 := make([]int, 0, len(data)*8/5+1)
for _, b := range data {
acc = ((acc << 8) | uint32(b)) & 0xffffffff
bits += 8
for bits >= 5 {
bits -= 5
data5 = append(data5, int((acc>>uint(bits))&31))
}
}
if bits > 0 {
data5 = append(data5, int((acc<<uint(5-bits))&31))
}
// checksum
values := append(cardanoBech32HrpExpand(hrp), data5...)
mod := cardanoBech32Polymod(append(values, 0, 0, 0, 0, 0, 0)) ^ cardanoBech32Const
for i := 0; i < 6; i++ {
data5 = append(data5, int((mod>>uint(5*(5-i)))&31))
}
out := hrp + "1"
for _, d := range data5 {
out += string(cardanoBech32Charset[d])
}
return out
}
// cardanoEncProtectedMapTest encodes { 1: -8, "address": raw } (sign side).
func cardanoEncProtectedMapTest(addr []byte) []byte {
out := cborEncodeHead(5, 2) // map(2)
out = append(out, cborEncodeHead(0, 1)...)
out = append(out, byte(0x27)) // -8 (negint: 0x20|7)
out = append(out, cborEncodeText("address")...)
out = append(out, cborEncodeBytes(addr)...)
return out
}
// cardanoEncCoseSign1Test encodes [protected, {hashed:false}, payload, sig].
func cardanoEncCoseSign1Test(protectedSer, payload, sig []byte) []byte {
out := cborEncodeHead(4, 4)
out = append(out, cborEncodeBytes(protectedSer)...)
out = append(out, cborEncodeHead(5, 1)...) // map(1)
out = append(out, cborEncodeText("hashed")...) // "hashed"
out = append(out, 0xf4) // false
out = append(out, cborEncodeBytes(payload)...)
out = append(out, cborEncodeBytes(sig)...)
return out
}
func TestCardanoRoundTripInGo(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 keygen: %v", err)
}
addrRaw, bech := cardanoBaseAddressInGo(t, pub)
msg := cardanoMessageInGo(t, bech)
protectedSer := cardanoEncProtectedMapTest(addrRaw)
payload := []byte(msg)
sigStruct := cborBuildSigStructure(protectedSer, []byte{}, payload)
sig := ed25519.Sign(priv, sigStruct)
coseSign1 := cardanoEncCoseSign1Test(protectedSer, payload, sig)
p := Proof{
Chain: ChainCardano,
Scheme: SchemeEd25519Cardano,
Address: bech,
PublicKey: hex.EncodeToString(pub),
Message: msg,
Signature: hex.EncodeToString(coseSign1),
}
if !VerifyCardano(p) {
t.Fatal("VerifyCardano(in-Go round-trip) = false, want true")
}
p.Message += "x"
if VerifyCardano(p) {
t.Fatal("VerifyCardano(tampered in-Go) = true, want false")
}
}
// TestCardanoLargeMessageRoundTrip proves maxSignatureLen clears the embedded
// payload: CIP-8 embeds the message in COSE_Sign1, so a large-but-legal message
// (< maxMessageLen) yields a signature ≈ 2× its size — which must exceed the old
// 4 KiB cap yet still be accepted. Mirrors the TS large-message regression.
func TestCardanoLargeMessageRoundTrip(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ed25519 keygen: %v", err)
}
addrRaw, bech := cardanoBaseAddressInGo(t, pub)
// ~3 KiB statement → message ~3.3 KiB → COSE sig hex ~6.6 KiB (> old 4 KiB).
statement := ""
for i := 0; i < 130; i++ {
statement += "I authorize this login. "
}
msg, err := BuildSiwxMessage(BuildParams{
Challenge: LoginChallenge{
Domain: "hanzo.id",
URI: "https://hanzo.id/login",
Statement: &statement,
Nonce: "adaLarge0001",
IssuedAt: "2026-01-01T00:00:00.000Z",
},
Address: bech,
Chain: ChainCardano,
})
if err != nil {
t.Fatalf("BuildSiwxMessage: %v", err)
}
protectedSer := cardanoEncProtectedMapTest(addrRaw)
payload := []byte(msg)
sigStruct := cborBuildSigStructure(protectedSer, []byte{}, payload)
sig := ed25519.Sign(priv, sigStruct)
coseSign1 := cardanoEncCoseSign1Test(protectedSer, payload, sig)
p := Proof{
Chain: ChainCardano,
Scheme: SchemeEd25519Cardano,
Address: bech,
PublicKey: hex.EncodeToString(pub),
Message: msg,
Signature: hex.EncodeToString(coseSign1),
}
// Sanity: the signature really exceeds the old 4 KiB cap (else test is moot).
if len(p.Signature) <= 4*1024 {
t.Fatalf("test setup: signature %d bytes, expected > 4096", len(p.Signature))
}
if !VerifyCardano(p) {
t.Fatal("VerifyCardano(large message) = false, want true")
}
// And the full dispatcher accepts it (with binding/time checks).
res := VerifyProof(p, Expectation{Domain: "hanzo.id", Nonce: "adaLarge0001", Now: mustTime(t, "2026-01-01T00:00:00.000Z")})
if !res.OK {
t.Fatalf("VerifyProof(large message) = %+v, want OK", res)
}
}
// --- fail-closed hardening ---
func TestCardanoRejectsUnknownScheme(t *testing.T) {
p := katCardanoBase.proof()
p.Scheme = SchemeEd25519 // not cardano
if VerifyCardano(p) {
t.Fatal("VerifyCardano(unknown scheme) = true, want false")
}
}
func TestCardanoDoesNotPanicOnGarbage(t *testing.T) {
garbage := Proof{
Chain: ChainCardano,
Scheme: SchemeEd25519Cardano,
Address: "!!!notbech32!!!",
PublicKey: "nothex",
Message: "not a siwx message",
Signature: "!!!!",
}
defer func() {
if r := recover(); r != nil {
t.Fatalf("VerifyCardano panicked on garbage: %v", r)
}
}()
if VerifyCardano(garbage) {
t.Fatal("VerifyCardano(garbage) = true, want false")
}
}
func TestCardanoRejectsWrongLengthPublicKey(t *testing.T) {
p := katCardanoBase.proof()
p.PublicKey = hex.EncodeToString(make([]byte, 31))
if VerifyCardano(p) {
t.Fatal("VerifyCardano(31-byte pubkey) = true, want false")
}
}
func TestCardanoFailsClosedOnMissingPublicKey(t *testing.T) {
p := katCardanoBase.proof()
p.PublicKey = ""
if VerifyCardano(p) {
t.Fatal("VerifyCardano(missing pubkey) = true, want false")
}
}
// --- end-to-end via VerifyProof (the one entry point) ---
func TestCardanoVerifyProofEndToEnd(t *testing.T) {
res := VerifyProof(katCardanoBase.proof(), Expectation{
Domain: "hanzo.id",
Nonce: "adaBase00001",
Now: mustTime(t, "2023-11-14T22:14:00.000Z"),
})
if !res.OK {
t.Fatalf("VerifyProof(cardano KAT) not OK: reason=%s", res.Reason)
}
if res.Chain != ChainCardano {
t.Fatalf("chain = %q, want cardano", res.Chain)
}
if res.Address != katCardanoBase.address {
t.Fatalf("address = %q, want %q", res.Address, katCardanoBase.address)
}
}
func TestCardanoVerifyProofRejectsWrongNonce(t *testing.T) {
res := VerifyProof(katCardanoBase.proof(), Expectation{
Domain: "hanzo.id",
Nonce: "WRONG",
Now: mustTime(t, "2023-11-14T22:14:00.000Z"),
})
if res.Reason != ReasonNonceMismatch {
t.Fatalf("reason = %q, want nonce-mismatch", res.Reason)
}
}