4

Recently, one new API Look Up Order ID was added into app store server API. And the JWSTransaction of this API response signed by the App Store, in JSON Web Signature format. We want to verify it with go.

What we have tried

  1. The jwt-go is used and we try to extract public key from pem file per this question. Also per this link, the response should be decoded by extracting a public key from private key
type JWSTransaction struct {
    BundleID             string `json:"bundleId"`
    InAppOwnershipType   string `json:"inAppOwnershipType"`
    TransactionID        string `json:"transactionId"`
    ProductID            string `json:"productId"`
    PurchaseDate         int64  `json:"purchaseDate"`
    Type                 string `json:"type"`
    OriginalPurchaseDate int64  `json:"originalPurchaseDate"`
}

func (ac *JWSTransaction) Valid() error {

    return nil
}

func (a *AppStore) readPrivateKeyFromFile(keyFile string) (*ecdsa.PrivateKey, error) {
    bytes, err := ioutil.ReadFile(keyFile)
    if err != nil {
        return nil, err
    }

    block, _ := pem.Decode(bytes)
    if block == nil {
        return nil, errors.New("appstore private key must be a valid .p8 PEM file")
    }

    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, errors.New("appstore private key must be of type ecdsa.PrivateKey")
    }
}

func (a *AppStore) ExtractClaims(tokenStr string) (*JWSTransaction, error) {
    privateKey, err := a.readPrivateKeyFromFile()
    if err != nil {
        return nil, err
    }
    
    publicKey, err := x509.MarshalPKIXPublicKey(privateKey.Public())
    if err != nil {
        return nil, err
    }
    fmt.Println(publicKey)

    tran := JWSTransaction{}

    token, err := jwt.ParseWithClaims(tokenStr, &tran, func(token *jwt.Token) (interface{}, error) {
        fmt.Println(token.Claims)
        fmt.Println(token.Method.Alg())

        return publicKey, nil
    })
    if err != nil {
        fmt.Println(err)
    }

However, the error key is of invalid type comes up from jwt.ParseWithClaims.

  1. Another way to verify it through the jwt-go and jwk packages per this link
    token, err := jwt.ParseWithClaims(tokenStr, &tran, func(token *jwt.Token) (interface{}, error) {
        fmt.Println(token.Claims)
        fmt.Println(token.Method.Alg())

        kid, ok := token.Header["kid"].(string)
        if !ok {
            return nil, errors.New("failed to find kid from headers")
        }
        key, found := keySet.LookupKeyID(kid)
        if !found {
            return nil, errors.New("failed to find kid from key set")
        }
        
        return publicKey, nil
    })

However, we failed to find the public key URL in app store server API doc. Also, there is no kid from the headers of JWSTransaction.

We want to know how to verify JWS transaction of app store server api in Go? Is there anything am I missing?

zangw
  • 43,869
  • 19
  • 177
  • 214
  • 1
    The x5c field in the JWS contains the full certificate chain. The first entry in x5c is the certificate that signed the JWS. – Paulw11 Oct 27 '21 at 10:26

3 Answers3

4

Thanks Paulw11 , Per doc

The "x5c" (X.509 certificate chain) Header Parameter contains the X.509 public key certificate or certificate chain [RFC5280] corresponding to the key used to digitally sign the JWS.

func (a *AppStore) extractPublicKeyFromToken(tokenStr string) (*ecdsa.PublicKey, error) {
    tokenArr := strings.Split(tokenStr, ".")
    headerByte, err := base64.RawStdEncoding.DecodeString(tokenArr[0])
    if err != nil {
        return nil, err
    }

    type Header struct {
        Alg string   `json:"alg"`
        X5c []string `json:"x5c"`
    }
    var header Header
    err = json.Unmarshal(headerByte, &header)
    if err != nil {
        return nil, err
    }

    certByte, err := base64.StdEncoding.DecodeString(header.X5c[0])
    if err != nil {
        return nil, err
    }

    cert, err := x509.ParseCertificate(certByte)
    if err != nil {
        return nil, err
    }

    switch pk := cert.PublicKey.(type) {
    case *ecdsa.PublicKey:
        return pk, nil
    default:
        return nil, errors.New("appstore public key must be of type ecdsa.PublicKey")
    }
}

func (a *AppStore) ExtractClaims(tokenStr string) (*JWSTransaction, error) {
    tran := &JWSTransaction{}
    _, err := jwt.ParseWithClaims(tokenStr, tran, func(token *jwt.Token) (interface{}, error) {
        return a.extractPublicKeyFromToken(tokenStr)
    })
    if err != nil {
        return nil, err
    }

    return tran, nil
}

Update 01/26/2022

In order to verify the root cert of x5c headers with apple root key from site

Refer to this loop. Here are sample codes

// Per doc: https://datatracker.ietf.org/doc/html/rfc7515#section-4.1.6
func (a *AppStore) extractPublicKeyFromToken(tokenStr string) (*ecdsa.PublicKey, error) {
    certStr, err := a.extractHeaderByIndex(tokenStr, 0)
    if err != nil {
        return nil, err
    }

    cert, err := x509.ParseCertificate(certStr)
    if err != nil {
        return nil, err
    }

    switch pk := cert.PublicKey.(type) {
    case *ecdsa.PublicKey:
        return pk, nil
    default:
        return nil, errors.New("appstore public key must be of type ecdsa.PublicKey")
    }
}

func (a *AppStore) extractHeaderByIndex(tokenStr string, index int) ([]byte, error) {
    if index > 2 {
        return nil, errors.New("invalid index")
    }

    tokenArr := strings.Split(tokenStr, ".")
    headerByte, err := base64.RawStdEncoding.DecodeString(tokenArr[0])
    if err != nil {
        return nil, err
    }

    type Header struct {
        Alg string   `json:"alg"`
        X5c []string `json:"x5c"`
    }
    var header Header
    err = json.Unmarshal(headerByte, &header)
    if err != nil {
        return nil, err
    }

    certByte, err := base64.StdEncoding.DecodeString(header.X5c[index])
    if err != nil {
        return nil, err
    }

    return certByte, nil
}

// rootPEM is from `openssl x509 -inform der -in AppleRootCA-G3.cer -out apple_root.pem`
const rootPEM = `
-----BEGIN CERTIFICATE-----
MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS
QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u
IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN
MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS
....
-----END CERTIFICATE-----
`

func (a *AppStore) verifyCert(certByte []byte) error {
    roots := x509.NewCertPool()
    ok := roots.AppendCertsFromPEM([]byte(rootPEM))
    if !ok {
        return errors.New("failed to parse root certificate")
    }

    cert, err := x509.ParseCertificate(certByte)
    if err != nil {
        return err
    }

    opts := x509.VerifyOptions{
        Roots: roots,
    }

    if _, err := cert.Verify(opts); err != nil {
        return err
    }

    return nil
}

func (a *AppStore) ExtractClaims(tokenStr string) (*JWSTransaction, error) {
    tran := &JWSTransaction{}

    rootCertStr, err := a.extractHeaderByIndex(tokenStr, 2)
    if err != nil {
        return nil, err
    }
    if err = a.verifyCert(rootCertStr); err != nil {
        return nil, err
    }

    _, err = jwt.ParseWithClaims(tokenStr, tran, func(token *jwt.Token) (interface{}, error) {
        return a.extractPublicKeyFromToken(tokenStr)
    })
    if err != nil {
        return nil, err
    }

    return tran, nil
}

Update 01/30/2022

Add verify intermediate certificate logic as below

func (a *AppStore) verifyCert(certByte, intermediaCertStr []byte) error {
    roots := x509.NewCertPool()
    ok := roots.AppendCertsFromPEM([]byte(rootPEM))
    if !ok {
        return errors.New("failed to parse root certificate")
    }

    interCert, err := x509.ParseCertificate(intermediaCertStr)
    if err != nil {
        return errors.New("failed to parse intermedia certificate")
    }
    intermedia := x509.NewCertPool()
    intermedia.AddCert(interCert)

    cert, err := x509.ParseCertificate(certByte)
    if err != nil {
        return err
    }

    opts := x509.VerifyOptions{
        Roots:         roots,
        Intermediates: intermedia,
    }

    chains, err := cert.Verify(opts)
    if err != nil {
        return err
    }

    for _, ch := range chains {
        for _, c := range ch {
            fmt.Printf("%+v, %s, %+v \n", c.AuthorityKeyId, c.Subject.Organization, c.ExtKeyUsage)
        }
    }

    return nil
}

func (a *AppStore) ExtractClaims(tokenStr string) (*JWSTransaction, error) {
    tran := &JWSTransaction{}

    rootCertStr, err := a.extractHeaderByIndex(tokenStr, 2)
    if err != nil {
        return nil, err
    }
    intermediaCertStr, err := a.extractHeaderByIndex(tokenStr, 1)
    if err != nil {
        return nil, err
    }
    if err = a.verifyCert(rootCertStr, intermediaCertStr); err != nil {
        return nil, err
    }

    _, err = jwt.ParseWithClaims(tokenStr, tran, func(token *jwt.Token) (interface{}, error) {
        return a.extractPublicKeyFromToken(tokenStr)
    })
    if err != nil {
        return nil, err
    }

    return tran, nil
}

The details of implementation could be found here https://github.com/richzw/appstore

zangw
  • 43,869
  • 19
  • 177
  • 214
  • Were you able to verify that the JWS was signed by apple? Only verifying that the signature matches up with x5c allows anyone to put their own. Looking to implement this as well – atultw Jan 08 '22 at 00:31
  • Yes, it could verify the JWS signed by apple – zangw Jan 08 '22 at 01:26
  • 1
    If you take the cert from the payload only and you don't verify it against something that is officially published by Apple (https://www.apple.com/certificateauthority/), then how can you be sure that it's not a forged cert? – thilonel Jan 25 '22 at 13:20
  • @thilonel, Per x5c doc, The "x5c" (X.509 certificate chain) Header Parameter contains the X.509 public key certificate or certificate chain. I think the certificate chain could include the Apple officially published certificate. Please correct me if I am wrong or something missing. – zangw Jan 25 '22 at 13:45
  • @zangw the way I see it: I as a nefarious actor could forge and provide a fake x509 cert chain in my payload, that I use to sign this JWT with. To prevent that: First from parsing the x5c content, you should be able to know which cert was used to sign this payload, then compare those with the officially released Apple certs (download it from the link above). Second, you validate that that cert was indeed used to sign this JWT. (what your code does) – thilonel Jan 25 '22 at 14:19
  • @zangw I'm testing against a sandbox notification, there they are using the Worldwide Developer Relations G6 to sign. I think it's enough if you validate that the pubkey of the first x5c cert matches the pubkey of this cert: https://www.apple.com/certificateauthority/AppleWWDRCAG6.cer Maybe there's more to it, I'm really not an expert on this... Looking forward to your addition! :) – thilonel Jan 25 '22 at 15:20
  • @thilonel, thank you very much for your update, I am not an expert either. And I will dig into it – zangw Jan 25 '22 at 15:32
  • Ok so on the Apple Certificate Authority you can find the WWDRCAG6 (intermediate) and the Root G3 (root) certs. You can't find the one they use to sign though. So what I've done is that I parsed and compared the certs from x5c with these downloaded ones, then verified the chain step by step using CheckSignatureFrom method from x509 Go package. I still don't know if that's enough or not! Here's a gist: https://gist.github.com/thilonel/7b7285b9bad211cc88a1b8ccfdbe9e0e Please let me know what you think! – thilonel Jan 25 '22 at 18:12
  • @thilonel, I found [one loop](https://stackoverflow.com/a/69504672/3011380) to discuss this issue. It seems just verifying the certs on the x5c header is enough... – zangw Jan 26 '22 at 05:51
  • @thilonel, with the root ca Apple Root CA - G3 Root, and after running `openssl x509 -inform der -in AppleRootCA-G3.cer -out apple_root.pem`, the content of apple_root.pem is same as the last ca of x5c headers. – zangw Jan 26 '22 at 06:12
  • @thilonel, I have updated my answer attached with root cert verified logic. Please let me know if there is any issue. – zangw Jan 26 '22 at 08:09
  • @zangw yeah it looks to me like this verifies the signing cert using apple's root cert - apparently without the need of adding the intermediate cert. I'm not sure what's the difference between Verify and CheckSignature functiont though. Last thing to note is that Verify does not check for revocation! – thilonel Jan 28 '22 at 15:47
  • @thilonel, I did get a chance to do the cert chain verification. IMHO, maybe it is no need to check cert revocation. – zangw Jan 29 '22 at 04:45
  • @thilonel, I updated my answer with intermediate cert verification logic – zangw Jan 30 '22 at 06:40
  • 1
    @zangw cool! I hope this will help others! :) – thilonel Jan 30 '22 at 08:35
2

We really need a golang library that can do this, i'm currently implementing a server callback, could combine it in an open source library so its easier to implement in golang.

TjerkW
  • 2,086
  • 21
  • 26
  • I just post a tiny library, https://github.com/richzw/appstore, and more cases will be updated later. Hope it will help you – zangw Mar 04 '22 at 11:00
2

Correct me if I'm wrong but the highest score answer doesn't seem correct to me. The certificate chain is not validated and the public key used to validate the message comes from the leaf certificate which is not validated at all. It looks like I could forge a fake message by putting a certificate chain in the x5c field of the JWSDecodedHeader as such:

  1. My own issued certificate
  2. Apple's intermediate certificate
  3. Apple's root certificate Since this code is only validating the authenticity of the last 2 and not checking if the first one is issued by the second one, I can put whatever I want there.

I think that in order to verify that the first certificate in the chain is valid, it's missing this:

certStr, err := a.extractHeaderByIndex(tokenStr, 0)
if err != nil {
    return nil, err
}

cert, err := x509.ParseCertificate(certStr)
if err != nil {
    return nil, err
}

opts := x509.VerifyOptions{
    Roots:         roots,
    Intermediates: intermediates,
}

chains, err := cert.Verify(opts)
if err != nil {
    return err
}