同步历史版本代码
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
package apple
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrInvalidJWS = errors.New("invalid jws")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user