javascriptapp-storeasn.1pkcs#7

How to decode PKCS#7/ASN.1 using Javascript?


Recently I started to work on Apple app store receipt validation and due to the deprecation of the legacy /verifyReceipt endpoint, I decided to go for on-device validation. The guideline described gives a step-by-step solution for MacOS however we want to perform this validation in the backend service using NodeJs. For the purpose of this validation information defined in the PCKS7# container is required to be decoded. Here my knowledge comes short as I am unable to retrieve this information (e.g. receipt_creation_data, bundle_id) I managed to convert the receipt from PCKS7 to ASN.1 but could not find a way to retrieve the actual key values from it. I tried several libraries like node-forge, asn1js, asn1.js. What I found really useful were these resources:

AFAIK the information should be encoded in OCTET STRING format enter image description here

How can information such as bundle_id or receipt_creation_date be retrieved from ASN.1 using Javascript?


Solution

  • With the help of everyone who commented I understood what needed to be done. Here is my very premature implementation of the receipt parser. The key is to define the proper ASN.1 schema that will be used for BER verification. asn1js package showed as very well documented and also well working.

    import * as asn1js from "asn1js"
    
    export class ReceiptParser {
      protected readonly PKCS7_DATA_SCHEMA_ID = "PKCS7Data"
    
      protected readonly FT_TYPE_BASE_ID = "FieldType"
    
      protected readonly FT_TYPE_OCTET_STRING_ID = "FieldTypeOctetString"
    
      protected readonly fieldTypesMap: Map<number, string> = new Map([
        [0, "FT_STAGE"],
        [2, "FT_BUNDLE_ID"],
        [3, "FT_APPLICATION_VERSION"],
        [4, "FT_OPAQUE_VALUE"],
        [5, "FT_SHA1_HASH"],
        [12, "FT_RECEIPT_CREATION_DATE"],
        [17, "FT_IN_APP"],
        [18, "FT_ORIGINAL_PURCHASE_DATE"],
        [19, "FT_ORIGINAL_APPLICATION_VERSION"],
        [21, "FT_EXPIRATION_DATE"],
      ])
    
      protected readonly schema = new asn1js.Sequence({
        value: [
          new asn1js.ObjectIdentifier(),
          new asn1js.Constructed({
            idBlock: {tagClass: 3, tagNumber: 0},
            value: [
              new asn1js.Sequence({
                value: [
                  new asn1js.Integer(),
                  new asn1js.Set({
                    value: [
                      new asn1js.Sequence({
                        value: [new asn1js.ObjectIdentifier(), new asn1js.Any()],
                      }),
                    ],
                  }),
                  new asn1js.Sequence({
                    value: [
                      new asn1js.ObjectIdentifier(),
                      new asn1js.Constructed({
                        idBlock: {tagClass: 3, tagNumber: 0},
                        value: [
                          new asn1js.OctetString({name: this.PKCS7_DATA_SCHEMA_ID}),
                        ],
                      }),
                    ],
                  }),
                ],
              }),
            ],
          }),
        ],
      })
    
      public parse(receiptString: string) {
        const rootSchemaVerification = asn1js.verifySchema(
          Buffer.from(receiptString, "base64"),
          this.schema
        )
    
        if (!rootSchemaVerification.verified) {
          throw new Error("Receipt is not valid")
        }
    
        const pkcs7Data = rootSchemaVerification.result[
          this.PKCS7_DATA_SCHEMA_ID
        ] as asn1js.OctetString
    
        const fieldTypesSet = pkcs7Data.valueBlock.value[0]
    
        if (!this.isSet(fieldTypesSet)) {
          throw new Error("Receipt is not valid")
        }
    
        const resultMap = new Map<string, string>()
    
        fieldTypesSet.valueBlock.value.forEach((sequence) => {
          const verifiedSequence = asn1js.verifySchema(
            (sequence as asn1js.Sequence).toBER(),
            new asn1js.Sequence({
              value: [
                new asn1js.Integer({name: this.FT_TYPE_BASE_ID}),
                new asn1js.Integer(),
                new asn1js.OctetString({name: this.FT_TYPE_OCTET_STRING_ID}),
              ],
            })
          )
    
          if (!verifiedSequence.verified) {
            return
          }
    
          const fieldTypeKey = verifiedSequence.result[
            this.FT_TYPE_BASE_ID
          ] as asn1js.Integer
          const integerValue = fieldTypeKey.valueBlock.valueDec
    
          if (!this.fieldTypesMap.has(integerValue)) {
            return
          }
    
          const octetValueId = this.FT_TYPE_OCTET_STRING_ID
          const octetValue = verifiedSequence.result[
            octetValueId
          ] as asn1js.OctetString
    
          const valueEncodedWithinOctetString = octetValue.valueBlock.value[0]
    
          if (valueEncodedWithinOctetString instanceof asn1js.IA5String) {
            const result = valueEncodedWithinOctetString.valueBlock.value
            resultMap.set(this.fieldTypesMap.get(integerValue)!, result)
          }
    
          if (valueEncodedWithinOctetString instanceof asn1js.Utf8String) {
            const result = valueEncodedWithinOctetString.valueBlock.value
            resultMap.set(this.fieldTypesMap.get(integerValue)!, result)
          }
        })
    
        return resultMap.get("FT_BUNDLE_ID")
      }
    
      protected isConstructed(data: unknown): data is asn1js.Constructed {
        return Boolean(data && data instanceof asn1js.Constructed)
      }
    
      protected isSequence(data: unknown): data is asn1js.Sequence {
        return Boolean(data && data instanceof asn1js.Sequence)
      }
    
      protected isSet(data: unknown): data is asn1js.Set {
        return Boolean(data && data instanceof asn1js.Set)
      }
    
      protected isInteger(data: unknown): data is asn1js.Integer {
        return Boolean(data && data instanceof asn1js.Integer)
      }
    }