同步历史版本代码

This commit is contained in:
2026-03-03 09:32:22 -08:00
parent 7d46b31866
commit 4d8516b2e1
140 changed files with 8399 additions and 489 deletions
+6
View File
@@ -0,0 +1,6 @@
package apple
import "errors"
var ErrInvalidJWS = errors.New("invalid jws")
+133
View File
@@ -0,0 +1,133 @@
package apple
import (
"crypto/ecdsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"math/big"
"strings"
"time"
"unicode"
)
func cleanB64(s string) string {
trimmed := strings.TrimSpace(s)
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return -1
}
return r
}, trimmed)
}
func decodeB64URL(s string) ([]byte, error) {
s = cleanB64(s)
if b, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return b, nil
}
switch len(s) % 4 {
case 2:
s += "=="
case 3:
s += "="
}
return base64.URLEncoding.DecodeString(s)
}
func ParseTransactionJWS(jws string) (*TransactionPayload, error) {
parts := strings.Split(strings.TrimSpace(jws), ".")
if len(parts) != 3 {
return nil, ErrInvalidJWS
}
data, err := decodeB64URL(parts[1])
if err != nil {
return nil, err
}
var raw map[string]interface{}
if err = json.Unmarshal(data, &raw); err != nil {
return nil, err
}
var resp TransactionPayload
if v, ok := raw["bundleId"].(string); ok {
resp.BundleId = v
}
if v, ok := raw["productId"].(string); ok {
resp.ProductId = v
}
if v, ok := raw["transactionId"].(string); ok {
resp.TransactionId = v
}
if v, ok := raw["originalTransactionId"].(string); ok {
resp.OriginalTransactionId = v
}
if v, ok := raw["purchaseDate"].(float64); ok {
resp.PurchaseDate = time.UnixMilli(int64(v))
} else if v, ok := raw["purchaseDate"].(int64); ok {
resp.PurchaseDate = time.UnixMilli(v)
}
if v, ok := raw["revocationDate"].(float64); ok {
t := time.UnixMilli(int64(v))
resp.RevocationDate = &t
}
if v, ok := raw["appAccountToken"].(string); ok {
resp.AppAccountToken = v
}
return &resp, nil
}
type jwsHeader struct {
Alg string `json:"alg"`
X5c []string `json:"x5c"`
Kid string `json:"kid"`
}
func VerifyTransactionJWS(jws string) (*TransactionPayload, error) {
parts := strings.Split(strings.TrimSpace(jws), ".")
if len(parts) != 3 {
return nil, ErrInvalidJWS
}
var hdr jwsHeader
hdrBytes, err := decodeB64URL(parts[0])
if err != nil {
return nil, err
}
if err = json.Unmarshal(hdrBytes, &hdr); err != nil {
return nil, err
}
if len(hdr.X5c) == 0 {
return nil, ErrInvalidJWS
}
certDer, err := base64.StdEncoding.DecodeString(hdr.X5c[0])
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(certDer)
if err != nil {
return nil, err
}
pub, ok := cert.PublicKey.(*ecdsa.PublicKey)
if !ok {
return nil, ErrInvalidJWS
}
signingInput := cleanB64(parts[0]) + "." + cleanB64(parts[1])
sigBytes, err := decodeB64URL(parts[2])
if err != nil {
return nil, err
}
d := sha256.Sum256([]byte(signingInput))
// Try ASN.1 signature first
if ecdsa.VerifyASN1(pub, d[:], sigBytes) {
return ParseTransactionJWS(jws)
}
// Fallback: raw R||S (JWS ES256 uses raw signature)
if len(sigBytes) == 64 {
r := new(big.Int).SetBytes(sigBytes[:32])
s := new(big.Int).SetBytes(sigBytes[32:])
if ecdsa.Verify(pub, d[:], r, s) {
return ParseTransactionJWS(jws)
}
}
return nil, ErrInvalidJWS
}
+35
View File
@@ -0,0 +1,35 @@
package apple
import (
"encoding/base64"
"encoding/json"
"testing"
"time"
)
func TestParseTransactionJWS(t *testing.T) {
payload := map[string]interface{}{
"bundleId": "co.airoport.app.ios",
"productId": "com.airport.vpn.pass.30d",
"transactionId": "1000000000001",
"originalTransactionId": "1000000000000",
"purchaseDate": float64(time.Now().UnixMilli()),
}
data, _ := json.Marshal(payload)
b64 := base64.RawURLEncoding.EncodeToString(data)
jws := "header." + b64 + ".signature"
p, err := ParseTransactionJWS(jws)
if err != nil {
t.Fatalf("parse error: %v", err)
}
if p.ProductId != payload["productId"] {
t.Fatalf("productId not match")
}
if p.BundleId != payload["bundleId"] {
t.Fatalf("bundleId not match")
}
if p.OriginalTransactionId != payload["originalTransactionId"] {
t.Fatalf("originalTransactionId not match")
}
}
+51
View File
@@ -0,0 +1,51 @@
package apple
import (
"encoding/json"
"strings"
)
type NotificationEnvelope struct {
NotificationType string
TransactionJWS string
}
func ParseNotificationSignedPayload(jws string) (*NotificationEnvelope, error) {
parts := strings.Split(strings.TrimSpace(jws), ".")
if len(parts) != 3 {
return nil, ErrInvalidJWS
}
data, err := decodeB64URL(parts[1])
if err != nil {
return nil, err
}
var raw map[string]interface{}
if err = json.Unmarshal(data, &raw); err != nil {
return nil, err
}
env := &NotificationEnvelope{}
if v, ok := raw["notificationType"].(string); ok {
env.NotificationType = v
}
if d, ok := raw["data"].(map[string]interface{}); ok {
if sjws, ok := d["signedTransactionInfo"].(string); ok {
env.TransactionJWS = sjws
}
}
return env, nil
}
func VerifyNotificationSignedPayload(jws string) (*TransactionPayload, string, error) {
env, err := ParseNotificationSignedPayload(jws)
if err != nil {
return nil, "", err
}
if env.TransactionJWS == "" {
return nil, env.NotificationType, ErrInvalidJWS
}
tx, err := VerifyTransactionJWS(env.TransactionJWS)
if err != nil {
return nil, env.NotificationType, err
}
return tx, env.NotificationType, nil
}
+39
View File
@@ -0,0 +1,39 @@
package apple
import (
"encoding/json"
"time"
)
type ProductMapping struct {
DurationDays int64 `json:"durationDays"`
Tier string `json:"tier"`
Description string `json:"description"`
PriceText string `json:"priceText"`
SubscribeId int64 `json:"subscribeId"`
}
type ProductMap struct {
Items map[string]ProductMapping `json:"iapProductMap"`
}
func ParseProductMap(customData string) (*ProductMap, error) {
if customData == "" {
return &ProductMap{Items: map[string]ProductMapping{}}, nil
}
var obj ProductMap
if err := json.Unmarshal([]byte(customData), &obj); err != nil {
return &ProductMap{Items: map[string]ProductMapping{}}, nil
}
if obj.Items == nil {
obj.Items = map[string]ProductMapping{}
}
return &obj, nil
}
func CalcExpire(start time.Time, days int64) time.Time {
if days <= 0 {
return time.UnixMilli(0)
}
return start.Add(time.Duration(days) * 24 * time.Hour)
}
+131
View File
@@ -0,0 +1,131 @@
package apple
import (
"bytes"
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"time"
)
type ServerAPIConfig struct {
KeyID string
IssuerID string
PrivateKey string
Sandbox bool
BundleID string
}
func buildAPIToken(cfg ServerAPIConfig) (string, error) {
header := map[string]interface{}{
"alg": "ES256",
"kid": cfg.KeyID,
"typ": "JWT",
}
now := time.Now().Unix()
payload := map[string]interface{}{
"iss": cfg.IssuerID,
"iat": now,
"exp": now + 1800,
"aud": "appstoreconnect-v1",
}
if cfg.BundleID != "" {
payload["bid"] = cfg.BundleID
}
hb, _ := json.Marshal(header)
pb, _ := json.Marshal(payload)
enc := func(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
unsigned := fmt.Sprintf("%s.%s", enc(hb), enc(pb))
block, _ := pem.Decode([]byte(cfg.PrivateKey))
if block == nil {
return "", fmt.Errorf("invalid private key")
}
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return "", err
}
priv, ok := keyAny.(*ecdsa.PrivateKey)
if !ok {
return "", fmt.Errorf("private key is not ECDSA")
}
// Correctly calculate R and S for ES256 (P-256 + SHA-256)
digest := sha256Sum([]byte(unsigned))
r, s, err := ecdsa.Sign(rand.Reader, priv, digest)
if err != nil {
return "", err
}
// Concatenate R and S (each 32 bytes for P-256)
curveBits := priv.Curve.Params().BitSize
keyBytes := curveBits / 8
if curveBits%8 > 0 {
keyBytes += 1
}
rBytes := r.Bytes()
rBytesPadded := make([]byte, keyBytes)
copy(rBytesPadded[keyBytes-len(rBytes):], rBytes)
sBytes := s.Bytes()
sBytesPadded := make([]byte, keyBytes)
copy(sBytesPadded[keyBytes-len(sBytes):], sBytes)
sig := append(rBytesPadded, sBytesPadded...)
return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}
func sha256Sum(b []byte) []byte {
h := sha256.New()
h.Write(b)
return h.Sum(nil)
}
func GetTransactionInfo(cfg ServerAPIConfig, transactionId string) (string, error) {
token, err := buildAPIToken(cfg)
if err != nil {
return "", err
}
try := func(host string) (string, int, string, error) {
url := fmt.Sprintf("%s/inApps/v1/transactions/%s", host, transactionId)
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", 0, "", err
}
defer resp.Body.Close()
buf := new(bytes.Buffer)
_, _ = buf.ReadFrom(resp.Body)
if resp.StatusCode != 200 {
return "", resp.StatusCode, buf.String(), fmt.Errorf("apple api error: %d", resp.StatusCode)
}
var body struct {
SignedTransactionInfo string `json:"signedTransactionInfo"`
}
if err := json.Unmarshal(buf.Bytes(), &body); err != nil {
return "", resp.StatusCode, buf.String(), err
}
return body.SignedTransactionInfo, resp.StatusCode, buf.String(), nil
}
primary := "https://api.storekit.itunes.apple.com"
secondary := "https://api.storekit-sandbox.itunes.apple.com"
if cfg.Sandbox {
primary, secondary = secondary, primary
}
jws, code, body, err := try(primary)
if err == nil && jws != "" {
return jws, nil
}
// Fallback to the other environment if primary failed (common when env mismatches)
jws2, code2, body2, err2 := try(secondary)
if err2 == nil && jws2 != "" {
return jws2, nil
}
return "", fmt.Errorf("apple api error, primary[%d:%s], secondary[%d:%s]", code, body, code2, body2)
}
+14
View File
@@ -0,0 +1,14 @@
package apple
import "time"
type TransactionPayload struct {
BundleId string `json:"bundleId"`
ProductId string `json:"productId"`
TransactionId string `json:"transactionId"`
OriginalTransactionId string `json:"originalTransactionId"`
PurchaseDate time.Time `json:"purchaseDate"`
RevocationDate *time.Time`json:"revocationDate"`
AppAccountToken string `json:"appAccountToken"`
}