Files
zeekay bd850a1b38 feat: complete 5-chain verifier core (TS) + Go port + IAM plan
TS verifiers: EVM, Solana, TON (ton_proof), XRP (secp256k1+ed25519), Bitcoin
(legacy + BIP-322 simple P2WPKH/P2TR) — 61 tests green, typecheck clean.

Go port (go/walletconnect): EVM + Solana via github.com/luxfi/crypto (NO
go-ethereum/ava-labs), TON/XRP/Bitcoin stubs fail-closed — 23 tests green.

IAM integration plan + drafts (integrations/iam/): canonical /v1/iam/web3/
{nonce,verify}, single-use nonce store, multi-wallet WalletLink. Found current
IAM web3 login verifies NO signature (security hole this closes).
2026-06-22 22:47:36 -07:00

193 lines
6.7 KiB
Go

package walletconnect
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"testing"
"time"
luxcrypto "github.com/luxfi/crypto"
"github.com/mr-tron/base58"
)
func nowTime() time.Time { return time.UnixMilli(fixedNow) }
func baseChallenge() LoginChallenge {
return newChallenge(challengeOpts{
domain: "hanzo.id",
uri: "https://hanzo.id/login",
nonce: "abc12345",
nowMs: fixedNow,
})
}
func TestVerifyProof_AcceptsFreshEVM(t *testing.T) {
c := baseChallenge()
priv, err := luxcrypto.GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
address := AddressFromPublicKey(luxcrypto.FromECDSAPub(&priv.PublicKey))
msg := mustBuild(t, BuildParams{Challenge: c, Address: address, Chain: ChainEVM})
sig, err := luxcrypto.Sign(EIP191Digest(msg), priv)
if err != nil {
t.Fatalf("Sign: %v", err)
}
sig[64] += 27
proof := Proof{
Chain: ChainEVM,
Scheme: SchemeSecp256k1EIP191,
Address: address,
Message: msg,
Signature: "0x" + hex.EncodeToString(sig),
}
res := VerifyProof(proof, Expectation{Domain: "hanzo.id", Nonce: "abc12345", Now: nowTime()})
if !res.OK {
t.Fatalf("expected OK, got reason %q", res.Reason)
}
if res.Address != address {
t.Errorf("address = %q want %q", res.Address, address)
}
if res.Chain != ChainEVM {
t.Errorf("chain = %q", res.Chain)
}
}
func freshSolanaProof(t *testing.T, c LoginChallenge) (Proof, string) {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
address := base58.Encode(pub)
msg := mustBuild(t, BuildParams{Challenge: c, Address: address, Chain: ChainSolana})
sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(msg)))
return Proof{Chain: ChainSolana, Scheme: SchemeEd25519, Address: address, Message: msg, Signature: sig}, address
}
func TestVerifyProof_AcceptsFreshSolana(t *testing.T) {
proof, _ := freshSolanaProof(t, baseChallenge())
res := VerifyProof(proof, Expectation{Domain: "hanzo.id", Nonce: "abc12345", Now: nowTime()})
if !res.OK {
t.Fatalf("expected OK, got reason %q", res.Reason)
}
}
func TestVerifyProof_RejectsWrongNonceAndDomain(t *testing.T) {
// Address must match the message; signature need not verify because the
// nonce/domain checks run before crypto. Build a real signer to satisfy the
// address binding, then assert the pre-crypto reason.
proof, _ := freshSolanaProof(t, baseChallenge())
if r := VerifyProof(proof, Expectation{Domain: "hanzo.id", Nonce: "WRONG", Now: nowTime()}).Reason; r != ReasonNonceMismatch {
t.Errorf("nonce: reason = %q want %q", r, ReasonNonceMismatch)
}
if r := VerifyProof(proof, Expectation{Domain: "evil.com", Nonce: "abc12345", Now: nowTime()}).Reason; r != ReasonDomainMismatch {
t.Errorf("domain: reason = %q want %q", r, ReasonDomainMismatch)
}
}
func TestVerifyProof_RejectsExpired(t *testing.T) {
c := newChallenge(challengeOpts{
domain: "hanzo.id",
uri: "https://hanzo.id/login",
nonce: "abc12345",
nowMs: fixedNow,
ttlSeconds: 60,
})
proof, _ := freshSolanaProof(t, c)
// now is 1h after issuance, well past 60s ttl + default 5m skew.
res := VerifyProof(proof, Expectation{
Domain: "hanzo.id",
Nonce: "abc12345",
Now: time.UnixMilli(fixedNow + 3_600_000),
})
if res.Reason != ReasonExpired {
t.Fatalf("reason = %q want %q", res.Reason, ReasonExpired)
}
}
func TestVerifyProof_RejectsNotYetValid(t *testing.T) {
// notBefore far in the future => not-yet-valid.
c := baseChallenge()
nbf := isoFromEpochMs(fixedNow + 3_600_000)
c.NotBefore = &nbf
proof, _ := freshSolanaProof(t, c)
res := VerifyProof(proof, Expectation{Domain: "hanzo.id", Nonce: "abc12345", Now: nowTime()})
if res.Reason != ReasonNotYetValid {
t.Fatalf("reason = %q want %q", res.Reason, ReasonNotYetValid)
}
}
func TestVerifyProof_RejectsAddressMismatch(t *testing.T) {
// Message embeds a different address than the proof claims.
proof, _ := freshSolanaProof(t, baseChallenge())
proof.Address = randSolanaAddress(t)
res := VerifyProof(proof, Expectation{Domain: "hanzo.id", Nonce: "abc12345", Now: nowTime()})
if res.Reason != ReasonAddressMismatch {
t.Fatalf("reason = %q want %q", res.Reason, ReasonAddressMismatch)
}
}
func TestVerifyProof_MalformedMessage(t *testing.T) {
proof := Proof{Chain: ChainEVM, Scheme: SchemeSecp256k1EIP191, Address: "0xabc", Message: "garbage", Signature: "0x00"}
res := VerifyProof(proof, Expectation{Domain: "hanzo.id", Nonce: "abc12345", Now: nowTime()})
if res.Reason != ReasonMalformedMessage {
t.Fatalf("reason = %q want %q", res.Reason, ReasonMalformedMessage)
}
}
func TestVerifyProof_FailsClosedUnknownScheme(t *testing.T) {
c := baseChallenge()
msg := mustBuild(t, BuildParams{Challenge: c, Address: "rXYZ", Chain: ChainXRP})
proof := Proof{Chain: ChainXRP, Scheme: SignatureScheme("totally-unknown"), Address: "rXYZ", Message: msg, Signature: "00"}
res := VerifyProof(proof, Expectation{Domain: "hanzo.id", Nonce: "abc12345", Now: nowTime()})
if res.Reason != ReasonUnsupportedScheme {
t.Fatalf("reason = %q want %q", res.Reason, ReasonUnsupportedScheme)
}
}
func TestVerifyProof_FailsClosedWiredButUnverifiable(t *testing.T) {
// A known-but-stubbed scheme (XRPL) wired with junk: passes parse/domain/
// nonce/time, then the crypto stub returns false => bad-signature.
c := baseChallenge()
msg := mustBuild(t, BuildParams{Challenge: c, Address: "rXYZ", Chain: ChainXRP})
proof := Proof{Chain: ChainXRP, Scheme: SchemeSecp256k1XRPL, Address: "rXYZ", Message: msg, Signature: "00"}
res := VerifyProof(proof, Expectation{Domain: "hanzo.id", Nonce: "abc12345", Now: nowTime()})
if res.OK {
t.Fatal("expected fail-closed")
}
if res.Reason != ReasonBadSignature {
t.Fatalf("reason = %q want %q", res.Reason, ReasonBadSignature)
}
}
func TestVerifyProof_ExpectedAddressMismatch(t *testing.T) {
// Proof verifies, but Expectation.Address pins a different address (EVM
// case-insensitive path).
c := baseChallenge()
priv, err := luxcrypto.GenerateKey()
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
address := AddressFromPublicKey(luxcrypto.FromECDSAPub(&priv.PublicKey))
msg := mustBuild(t, BuildParams{Challenge: c, Address: address, Chain: ChainEVM})
sig, err := luxcrypto.Sign(EIP191Digest(msg), priv)
if err != nil {
t.Fatalf("Sign: %v", err)
}
sig[64] += 27
proof := Proof{Chain: ChainEVM, Scheme: SchemeSecp256k1EIP191, Address: address, Message: msg, Signature: "0x" + hex.EncodeToString(sig)}
res := VerifyProof(proof, Expectation{
Domain: "hanzo.id",
Nonce: "abc12345",
Address: "0x0000000000000000000000000000000000000000",
Now: nowTime(),
})
if res.Reason != ReasonAddressMismatch {
t.Fatalf("reason = %q want %q", res.Reason, ReasonAddressMismatch)
}
}