package alipay_test import ( "context" "crypto" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/pem" "net/url" "sort" "strings" "testing" "time" sw "github.com/smartwalle/alipay/v3" "github.com/wangjia/pay/internal/provider" ali "github.com/wangjia/pay/internal/provider/alipay" ) // 生成一对 RSA 密钥:appPriv 供 client 下单签名;aliPriv/aliPub 冒充"支付宝侧"给异步通知签名/验签。 // // ⚠️ 与 brief 草稿的差异(执行时发现,已按 SDK 实际要求修正): // alipay 公钥 client.LoadAliPayPublicKey 内部走 ncrypto.DecodePublicKey(...).PKIX(), // 要求 PKIX(SubjectPublicKeyInfo)编码,不是 PKCS1 —— 用 MarshalPKCS1PublicKey 会在 // LoadAliPayPublicKey 报 "x509: failed to parse public key (use ParsePKCS1PublicKey // instead for this key format)"。改用 x509.MarshalPKIXPublicKey。app 私钥走 // ncrypto.DecodePrivateKey(...).PKCS1(),失败再退 PKCS8,MarshalPKCS1PrivateKey 不受影响。 func genKeys(t *testing.T) (appPrivPKCS1B64, aliPrivPKCS1B64, aliPubPKIXB64 string) { t.Helper() app, _ := rsa.GenerateKey(rand.Reader, 2048) aliK, _ := rsa.GenerateKey(rand.Reader, 2048) b64 := func(b []byte) string { return base64.StdEncoding.EncodeToString(b) } appPrivPKCS1B64 = b64(x509.MarshalPKCS1PrivateKey(app)) aliPrivPKCS1B64 = b64(x509.MarshalPKCS1PrivateKey(aliK)) aliPubDER, err := x509.MarshalPKIXPublicKey(&aliK.PublicKey) if err != nil { t.Fatalf("marshal ali pub: %v", err) } aliPubPKIXB64 = b64(aliPubDER) return } func buildClient(t *testing.T, appPriv, aliPub string) *sw.Client { c, err := sw.New("2021000000000000", appPriv, false) // 沙箱 if err != nil { t.Fatalf("new client: %v", err) } if err := c.LoadAliPayPublicKey(aliPub); err != nil { t.Fatalf("load pub: %v", err) } return c } func TestCreateRedirect(t *testing.T) { appPriv, _, aliPub := genKeys(t) p := ali.New(buildClient(t, appPriv, aliPub)) sess, err := p.Create(context.Background(), provider.CreateRequest{ OutTradeNo: "PAY-1", Subject: "Pro 年付", AmountMinor: 19900, Currency: "CNY", ReturnURL: "https://x/return", }) if err != nil { t.Fatalf("create: %v", err) } if sess.RenderType != provider.RenderRedirect || sess.ProviderRef != "PAY-1" { t.Fatalf("session = %+v", sess) } u, _ := sess.Payload["url"].(string) if !strings.Contains(u, "alipay") { t.Fatalf("url = %q 不像收银台跳转", u) } } // 用"支付宝侧"私钥给一份通知表单签名,adapter 用装的公钥验签 → 归一化 PaidEvent。 func TestVerifyCallbackRSA(t *testing.T) { appPriv, aliPriv, aliPub := genKeys(t) p := ali.New(buildClient(t, appPriv, aliPub)) form := url.Values{} form.Set("out_trade_no", "PAY-1") form.Set("trade_no", "2021AAA") form.Set("trade_status", "TRADE_SUCCESS") form.Set("total_amount", "199.00") form.Set("sign_type", "RSA2") form.Set("sign", signRSA2(t, aliPriv, form)) ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())}) if err != nil { t.Fatalf("verify: %v", err) } if ev.ProviderRef != "PAY-1" || ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != 19900 || ev.PaidCurrency != "CNY" { t.Fatalf("event = %+v", ev) } // brief 草稿的回调表单未带 gmt_payment;PaidAt 应留 nil,交 settle 按 D4-A3 兜底用收到时间。 if ev.PaidAt != nil { t.Fatalf("未带 gmt_payment 时 PaidAt 应为 nil, got %v", ev.PaidAt) } } // D4-A3:VerifyCallback 应从支付宝回调的 gmt_payment(北京时间,无时区位)取真实付款时间, // 而不是让 settle 一律回退到"收到时间"——对账时才能跟渠道流水的付款时点对得上。 func TestVerifyCallbackRSA_PaidAtFromGmtPayment(t *testing.T) { appPriv, aliPriv, aliPub := genKeys(t) p := ali.New(buildClient(t, appPriv, aliPub)) form := url.Values{} form.Set("out_trade_no", "PAY-2") form.Set("trade_no", "2021BBB") form.Set("trade_status", "TRADE_SUCCESS") form.Set("total_amount", "1.00") form.Set("gmt_payment", "2026-07-10 15:04:05") form.Set("sign_type", "RSA2") form.Set("sign", signRSA2(t, aliPriv, form)) ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())}) if err != nil { t.Fatalf("verify: %v", err) } if ev.PaidAt == nil { t.Fatal("带 gmt_payment 时 PaidAt 不应为 nil") } want := time.Date(2026, 7, 10, 15, 4, 5, 0, time.FixedZone("CST", 8*3600)) if !ev.PaidAt.Equal(want) { t.Fatalf("PaidAt = %v, want %v", ev.PaidAt, want) } } // 攻击者拿一把与装配时加载的支付宝公钥不匹配的私钥伪造回调(冒充支付宝) → 验签 // 必须失败,且绝不能返回 PaidEvent(哪怕金额/状态字段看起来"正常"也不能被当真入账)。 func TestVerifyCallbackRSA_WrongKeyFails(t *testing.T) { appPriv, _, aliPub := genKeys(t) p := ali.New(buildClient(t, appPriv, aliPub)) _, wrongAliPriv, _ := genKeys(t) // 另一把毫不相干的"支付宝私钥" form := url.Values{} form.Set("out_trade_no", "PAY-EVIL") form.Set("trade_no", "2021EVIL") form.Set("trade_status", "TRADE_SUCCESS") form.Set("total_amount", "999999.00") form.Set("sign_type", "RSA2") form.Set("sign", signRSA2(t, wrongAliPriv, form)) ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())}) if err == nil { t.Fatalf("want 验签失败(签名私钥与装配公钥不匹配), got event = %+v", ev) } if ev != nil { t.Fatalf("验签失败时不应返回 PaidEvent, got %+v", ev) } } // TRADE_CLOSED(超时未付关闭/全额退款后关闭)须归一化为 failed 终态,不能停在 pending // 让 SyncPendingAttempts 空转到订单永远"待处理"。 func TestVerifyCallbackRSA_ClosedIsFailed(t *testing.T) { appPriv, aliPriv, aliPub := genKeys(t) p := ali.New(buildClient(t, appPriv, aliPub)) form := url.Values{} form.Set("out_trade_no", "PAY-3") form.Set("trade_no", "2021CCC") form.Set("trade_status", "TRADE_CLOSED") form.Set("total_amount", "1.00") form.Set("sign_type", "RSA2") form.Set("sign", signRSA2(t, aliPriv, form)) ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())}) if err != nil { t.Fatalf("verify: %v", err) } if ev.Status != provider.PaidFailed { t.Fatalf("TRADE_CLOSED 应归一化为 PaidFailed, got %v", ev.Status) } } // --------------------------------------------------------------------------- // 健壮性测试(补测项 ⑨):以上验签测试都是自生成密钥对自签自验(测的是我方 adapter // 逻辑本身,不是支付宝真实签名细节);本节仍是自签,但专挑真实回调报文常见的 // "互操作易碎点"——未知/多余字段、非字母序的字段排列、中文/unicode 值、可选字段 // 留空。⚠️ 真实支付宝黄金向量(官方真实回调报文的逐字节样本)需要抓真实线上/沙箱 // 回调抓包,本文件不具备,以下均是自签构造。 // --------------------------------------------------------------------------- // TestVerifyCallbackRSA_UnknownExtraFieldsIgnored 真实支付宝回调常带一大堆我方代码 // 不读取的字段(app_id/seller_email/buyer_logon_id/notify_id/charset/version/ // receipt_amount/point_amount...)。signRSA2 与 SDK 内部验签都按官方规则排除 // sign/sign_type 及空值字段参与签名重算,多余字段不应打断验签,也不应污染归一化 // 结果(ProviderRef/Status 只认 out_trade_no/trade_status/total_amount)。 func TestVerifyCallbackRSA_UnknownExtraFieldsIgnored(t *testing.T) { appPriv, aliPriv, aliPub := genKeys(t) p := ali.New(buildClient(t, appPriv, aliPub)) form := url.Values{} form.Set("out_trade_no", "PAY-EXTRA") form.Set("trade_no", "2021EXTRA") form.Set("trade_status", "TRADE_SUCCESS") form.Set("total_amount", "1.00") form.Set("sign_type", "RSA2") // 支付宝真实通知常见的额外字段,adapter 均不读取,只应被忽略。 form.Set("app_id", "2021000000000000") form.Set("auth_app_id", "2021000000000000") form.Set("notify_id", "notify-xyz") form.Set("notify_type", "trade_status_sync") form.Set("notify_time", "2026-07-10 15:04:05") form.Set("charset", "utf-8") form.Set("version", "1.0") form.Set("seller_id", "2088seller") form.Set("seller_email", "seller@example.com") form.Set("buyer_id", "2088buyer") form.Set("buyer_logon_id", "buy***@example.com") form.Set("receipt_amount", "1.00") form.Set("point_amount", "0.00") form.Set("sign", signRSA2(t, aliPriv, form)) ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())}) if err != nil { t.Fatalf("verify: %v", err) } if ev.ProviderRef != "PAY-EXTRA" || ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != 100 { t.Fatalf("event = %+v", ev) } } // TestVerifyCallbackRSA_FieldOrderIndependent 真实网关/CDN/负载均衡转发不保证 query // 字段在线路上的排列顺序;VerifyCallback 先 url.ParseQuery 解成 url.Values(map), // 理论上字段顺序不该影响结果——这里故意手拼一份与字母序相反的 wire bytes(而非用 // form.Encode() 那种天然按 key 字母序输出的路径)来钉住这一互操作预期。 func TestVerifyCallbackRSA_FieldOrderIndependent(t *testing.T) { appPriv, aliPriv, aliPub := genKeys(t) p := ali.New(buildClient(t, appPriv, aliPub)) form := url.Values{} form.Set("out_trade_no", "PAY-ORDER") form.Set("trade_no", "2021ORDER") form.Set("trade_status", "TRADE_SUCCESS") form.Set("total_amount", "1.00") form.Set("gmt_payment", "2026-07-10 15:04:05") form.Set("sign_type", "RSA2") sign := signRSA2(t, aliPriv, form) // 按官方排序算出的签名,与 wire 上字段实际排列顺序无关 raw := "sign_type=" + url.QueryEscape("RSA2") + "&trade_status=" + url.QueryEscape("TRADE_SUCCESS") + "&total_amount=" + url.QueryEscape("1.00") + "&gmt_payment=" + url.QueryEscape("2026-07-10 15:04:05") + "&out_trade_no=" + url.QueryEscape("PAY-ORDER") + "&trade_no=" + url.QueryEscape("2021ORDER") + "&sign=" + url.QueryEscape(sign) ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(raw)}) if err != nil { t.Fatalf("verify(乱序字段): %v", err) } if ev.ProviderRef != "PAY-ORDER" || ev.Status != provider.PaidSucceeded { t.Fatalf("event = %+v", ev) } if ev.PaidAt == nil { t.Fatal("乱序字段不应影响 gmt_payment 解析,PaidAt 不应为 nil") } } // TestVerifyCallbackRSA_UnicodeSubject subject/body 携带中文 + emoji(真实商品名称/ // 备注常见),须能正确 percent-encode/decode 并参与签名而不破坏验签或后续解析。 func TestVerifyCallbackRSA_UnicodeSubject(t *testing.T) { appPriv, aliPriv, aliPub := genKeys(t) p := ali.New(buildClient(t, appPriv, aliPub)) form := url.Values{} form.Set("out_trade_no", "PAY-CN") form.Set("trade_no", "2021CN") form.Set("trade_status", "TRADE_SUCCESS") form.Set("total_amount", "1.00") form.Set("subject", "Pro 年付 🎉 商品名称含中文与emoji") form.Set("body", "订单备注:测试中文正文——含标点、全角符号「」") form.Set("sign_type", "RSA2") form.Set("sign", signRSA2(t, aliPriv, form)) ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())}) if err != nil { t.Fatalf("verify: %v", err) } if ev.ProviderRef != "PAY-CN" || ev.Status != provider.PaidSucceeded || ev.PaidAmountMinor != 100 { t.Fatalf("event = %+v", ev) } } // TestVerifyCallbackRSA_EmptyValueFieldsAreIncludedInSignature 补测中意外挖出的真实 // SDK 行为,与"官方文档说空值参数不参与签名"的字面印象不一致,记录在案(避免下次 // 被当成 bug 重新踩一遍):github.com/smartwalle/alipay/v3 注入给签名器的是它自己 // vendor 的 Encoder(v3@v3.2.29/encode.go),不是 nsign 包默认的 DefaultEncoder—— // 前者对"空值字段"**不做排除**,只要 key 不在 ignore 名单(sign/sign_type/ // alipay_cert_sn)里,即便 value 是空字符串也会以 "key=" 形式进入签名源参与 hash; // nsign.DefaultEncoder 则会跳过空值。 // // 实测过程:先按"官方文档描述的排除空值"假设写了个用例(签名时排除空值、wire 上 // 带 3 个空值可选字段),结果 SDK 侧验签失败(crypto/rsa: verification error);手工 // 复算签名源字符串确认两侧字符串其实一致后,才定位到问题出在 vendor Encoder 与 // nsign.DefaultEncoder 的语义差异,而非我方 adapter 或字段解析逻辑的 bug。 // // 本用例改为验证 SDK 的**真实行为**:空值字段必须参与签名源才能验签通过(signRSA2 // 复刻的是"排除空值"这一支付宝官方文档口径,与此处刻意用 signIncludingEmpty 区分)。 // 真实支付宝服务器是否会在回调里真的带上空值字段未经抓包验证,但只要它带了, // 当前这版 SDK 要求空值也进签名源——这条钉住的是 SDK 版本的真实契约,供后续升级 // SDK 或换实现时对照。 func TestVerifyCallbackRSA_EmptyValueFieldsAreIncludedInSignature(t *testing.T) { appPriv, aliPriv, aliPub := genKeys(t) p := ali.New(buildClient(t, appPriv, aliPub)) form := url.Values{} form.Set("out_trade_no", "PAY-EMPTY") form.Set("trade_no", "2021EMPTY") form.Set("trade_status", "TRADE_SUCCESS") form.Set("total_amount", "1.00") form.Set("sign_type", "RSA2") form.Set("refund_status", "") form.Set("invoice_amount", "") form.Set("passback_params", "") form.Set("sign", signIncludingEmpty(t, aliPriv, form)) ev, err := p.VerifyCallback(context.Background(), provider.CallbackInput{Raw: []byte(form.Encode())}) if err != nil { t.Fatalf("verify(含空值可选字段): %v", err) } if ev.ProviderRef != "PAY-EMPTY" || ev.Status != provider.PaidSucceeded { t.Fatalf("event = %+v", ev) } } // signIncludingEmpty 复刻 alipay 包 vendor Encoder(见上面测试注释,非 nsign 默认 // DefaultEncoder)的真实签名规则:排序**全部**非 sign/sign_type 字段(空值也算入), // k=v&拼接,RSA-SHA256,base64。与本文件 signRSA2(严格照官方文档排除空值)刻意 // 区分,专供本测试还原 SDK 真实行为,不共用逻辑以免掩盖两者的语义差异。 func signIncludingEmpty(t *testing.T, aliPrivB64 string, form url.Values) string { t.Helper() der, _ := base64.StdEncoding.DecodeString(aliPrivB64) priv, err := x509.ParsePKCS1PrivateKey(der) if err != nil { t.Fatalf("parse ali priv: %v", err) } keys := make([]string, 0, len(form)) for k := range form { if k == "sign" || k == "sign_type" { continue } keys = append(keys, k) } sort.Strings(keys) var parts []string for _, k := range keys { parts = append(parts, k+"="+form.Get(k)) } h := sha256.Sum256([]byte(strings.Join(parts, "&"))) sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:]) if err != nil { t.Fatalf("sign: %v", err) } return base64.StdEncoding.EncodeToString(sig) } // signRSA2 复刻支付宝签名:排序非空参数(排除 sign/sign_type),k=v&拼接,RSA-SHA256,base64。 func signRSA2(t *testing.T, aliPrivB64 string, form url.Values) string { t.Helper() der, _ := base64.StdEncoding.DecodeString(aliPrivB64) priv, err := x509.ParsePKCS1PrivateKey(der) if err != nil { t.Fatalf("parse ali priv: %v", err) } keys := make([]string, 0, len(form)) for k := range form { if k == "sign" || k == "sign_type" || form.Get(k) == "" { continue } keys = append(keys, k) } sort.Strings(keys) var parts []string for _, k := range keys { parts = append(parts, k+"="+form.Get(k)) } h := sha256.Sum256([]byte(strings.Join(parts, "&"))) sig, err := rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, h[:]) if err != nil { t.Fatalf("sign: %v", err) } return base64.StdEncoding.EncodeToString(sig) } var _ = pem.Encode // 避免 import 未用(若不需要 pem 可删该 import 与本行)