mirror of
https://github.com/luxfi/connect.git
synced 2026-10-30 16:59:20 +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.
404 lines
14 KiB
Go
404 lines
14 KiB
Go
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)
|
||
}
|
||
}
|