一、背景

QQ群里一位兄弟问到生成苹果开发平台API token的方法,我一看jwt我知道啊,不就是header、payload再用算法、秘钥签个名拼起来不就完了?

二、碰壁

  1. 先阅读官方文档,得知计算jwt的步骤

    • 创建header,包含alg(签名算法)、typ(token类型)、kid(苹果后台获取到的私钥id),示例:

      {
      "alg": "ES256", // 签名算法
      "kid": "2X9R4HXF34", // 苹果后台获取到的私钥id
      "typ": "JWT" // token类型
      }
      
    • 创建payload,包含iss(issuer ID)、exp(过期时间,unix时间戳,以秒为单位,)、aud

      {
          "iss": "57246542-96fe-1a63-e053-0824d011072a",// issuer ID
          "exp": 1528408800, // token过期时间,unix时间戳,秒为单位,必须在20分钟以内
          "aud": "appstoreconnect-v1" // jwt接收方
      }
      
    • 计算签名

      使用ES256算法和苹果后台提供的私钥计算签名

  2. 根据文档提示,使用JWT.io进行网页版签名计算,根据要求输入参数,但一直提示非法签名

    file
    file

  3. 使用jwt-go计算签名

    • 尝试一,提示传入的可以是非法类型

      package main
      
      import (
      	"fmt"
      	"github.com/dgrijalva/jwt-go"
      	"time"
      )
      
      func main(){
      	token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
      		"iss": "57246542-96fe-1a63-e053-0824d011072a",
      		"exp": time.Now().Unix(),
      		"aud": "appstoreconnect-v1",
      	})
      	token.Header["kid"] = "2X9R4HXF34"
      	tokenString, err := token.SignedString("<privateKey>")
      	fmt.Println(tokenString, err)
      }
      
      ---------
      key is of invalid type
      
    • 尝试二,通过阅读源代码发现需要传入*ecdsa.PrivateKey类型的key

      package main
      
      import (
      	"crypto/ecdsa"
      	"fmt"
      	"github.com/dgrijalva/jwt-go"
      	"log"
      	"time"
      )
      
      func main(){
      	var (
      		ecdsaKey *ecdsa.PrivateKey
      		err error
      	)
      	ecdsaKey,err = jwt.ParseECPrivateKeyFromPEM([]byte("<privatekey>"))
      	if err != nil{
      		log.Fatal(err)
      	}
      	token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
      		"iss": "57246542-96fe-1a63-e053-0824d011072a",
      		"exp": time.Now().Unix(),
      		"aud": "appstoreconnect-v1",
      	})
      	token.Header["kid"] = "2X9R4HXF34"
      	tokenString, err := token.SignedString(ecdsaKey)
      	fmt.Println(tokenString, err)
      }
      ------------
      x509: failed to parse EC private key: asn1: structure error: tags don't match (4 vs {class:0 tag:16 length:19 isCompound:true}) {optional:false explicit:false application:false private:false defaultValue:<nil> tag:<nil> stringType:0 timeType:0 set:false omitEmpty:false}  @5
      

      file
      file

    • 尝试三,仔细观察下载到的privatekey文件,是以p8结尾的。在网上找到了解析p8文件的方法

      package main
      
      import (
      	"crypto/ecdsa"
      	"crypto/x509"
      	"encoding/pem"
      	"errors"
      	"fmt"
      	"github.com/dgrijalva/jwt-go"
      	"log"
      	"time"
      )
      
      var (
      	ErrAuthKeyNotPem   = errors.New("token: AuthKey must be a valid .p8 PEM file")
      	ErrAuthKeyNotECDSA = errors.New("token: AuthKey must be of type ecdsa.PrivateKey")
      	ErrAuthKeyNil      = errors.New("token: AuthKey was nil")
      )
      
      func AuthKeyFromBytes(bytes []byte) (*ecdsa.PrivateKey, error) {
      	block, _ := pem.Decode(bytes)
      	if block == nil {
      		return nil, ErrAuthKeyNotPem
      	}
      	key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
      	if err != nil {
      		return nil, err
      	}
      	switch pk := key.(type) {
      	case *ecdsa.PrivateKey:
      		return pk, nil
      	default:
      		return nil, ErrAuthKeyNotECDSA
      	}
      }
      
      func main() {
      	var (
      		ecdsaKey *ecdsa.PrivateKey
      		err      error
      	)
      	ecdsaKey, err = AuthKeyFromBytes([]byte("<privatekey>"))
      	if err != nil {
      		log.Fatal(err)
      	}
      	token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
      		"iss": "57246542-96fe-1a63-e053-0824d011072a",
      		"exp": time.Now().Unix(),
      		"aud": "appstoreconnect-v1",
      	})
      	token.Header["kid"] = "2X9R4HXF34"
      	tokenString, err := token.SignedString(ecdsaKey)
      	fmt.Println(tokenString, err)
      }
      -------
      eyJhbGciOiJFUzI1NiIsImtpZCI6IjJYOVI0SFhGMzQiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJhcHBzdG9yZWNvbm5lY3QtdjEiLCJleHAiOjE1NjEwODM1OTgsImlzcyI6IjU3MjQ2NTQyLTk2ZmUtMWE2My1lMDUzLTA4MjRkMDExMDcyYSJ9.E_wRsV0M7kM2aonqFBb6--Nagn5cU1mBqMOpIqJyOE34wPmcCg2-O2Ee02QbrbWHQ02rildvdiDMW8KeIWlemg <nil>
      
    • 尝试四,签名终于计算成功了!不过还不能高兴的太早,要请求下服务器试下。请求返回401 NOT_AUTHORIZED,想死的心都有了!!!

      $ curl -v -H 'Authorization: Bearer eyJhbGciOiJFUzI1NiIsImtpZCI6IjJYOVI0SFhGMzQiLCJ0eXAiOiJKV1QifQ.eyJhdWQiOiJhcHBzdG9yZWNvbm5lY3QtdjEiLCJleHAiOjE1NjEwODM1OTgsImlzcyI6IjU3MjQ2NTQyLTk2ZmUtMWE2My1lMDUzLTA4MjRkMDExMDcyYSJ9.E_wRsV0M7kM2aonqFBb6--Nagn5cU1mBqMOpIqJyOE34wPmcCg2-O2Ee02QbrbWHQ02rildvdiDMW8KeIWlemg' "https://api.appstoreconnect.apple.com/v1/apps"
      

      file
      file

三、柳暗花明

经过分析代码,原来是payload里面的exp值传错了,文档中提到过期时间在未来的20分钟之内,而我传的是当前时间。token传到服务器,服务器一看,过期了,当然拒绝了。所以最后的代码:

package main

import (
	"crypto/ecdsa"
	"crypto/x509"
	"encoding/pem"
	"errors"
	"fmt"
	"github.com/dgrijalva/jwt-go"
	"log"
	"time"
)

var (
	ErrAuthKeyNotPem   = errors.New("token: AuthKey must be a valid .p8 PEM file")
	ErrAuthKeyNotECDSA = errors.New("token: AuthKey must be of type ecdsa.PrivateKey")
	ErrAuthKeyNil      = errors.New("token: AuthKey was nil")
)

func AuthKeyFromBytes(bytes []byte) (*ecdsa.PrivateKey, error) {
	block, _ := pem.Decode(bytes)
	if block == nil {
		return nil, ErrAuthKeyNotPem
	}
	key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
	if err != nil {
		return nil, err
	}
	switch pk := key.(type) {
	case *ecdsa.PrivateKey:
		return pk, nil
	default:
		return nil, ErrAuthKeyNotECDSA
	}
}

func main() {
	var (
		ecdsaKey *ecdsa.PrivateKey
		err      error
	)
	ecdsaKey, err = AuthKeyFromBytes([]byte("<privatekey>"))
	if err != nil {
		log.Fatal(err)
	}
	token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
		"iss": "57246542-96fe-1a63-e053-0824d011072a",
		"exp": time.Now().Add(20*time.Minute).Unix(),
		"aud": "appstoreconnect-v1",
	})
	token.Header["kid"] = "2X9R4HXF34"
	tokenString, err := token.SignedString(ecdsaKey)
	fmt.Println(tokenString, err)
}

四、参考资料