apple-pay-helper
Version:
A utility library for handling server side Apple Pay sessions and decrypting tokens.
252 lines (212 loc) • 9.04 kB
JavaScript
const crypto = require("crypto");
const forge = require("node-forge");
const ECKey = require("ec-key");
const { X509Certificate } = require("@peculiar/x509");
const ApplePayHelper = require("./ApplePayHelper");
const pkijs = require("pkijs"); // needed
const asn1js = require("asn1js"); // needed
const TOKEN_EXPIRE_WINDOW = 30000; // should be set to 5 minutes (300000 ms) per apple
const LEAF_CERTIFICATE_OID = "1.2.840.113635.100.6.29";
const INTERMEDIATE_CA_OID = "1.2.840.113635.100.6.2.14";
const SIGNINGTIME_OID = "1.2.840.113549.1.9.5";
const MERCHANT_ID_FIELD_OID = "1.2.840.113635.100.6.32";
const webCrypto = require("@peculiar/webcrypto"); // needed
/**
* Class to handle the decryption of Apple Pay tokens.
*/
class PaymentToken {
/**
* @param {import('./applePayHelperTypes').ApplePaymentResponse_PaymentData} tokenAttrs - The Apple Pay token to be decrypted.
*/
constructor(tokenAttrs) {
this.tokenAttrs = tokenAttrs;
this.ephemeralPublicKey = tokenAttrs.header.ephemeralPublicKey;
this.cipherText = tokenAttrs.data;
}
/**
* Main method to decrypt the token.
* @param {string} certPem - Merchant certificate in PEM format.
* @param {string} privatePem - Merchant private key in PEM format.
* @param {boolean} validateExpiration - Whether to validate the expiration of the token.
* @param {number} [expirationWindow=300000] - The expiration window in milliseconds.
* @returns { import('./applePayHelperTypes').DecryptedTokenRaw } Decrypted token data.
*/
decrypt(
certPem,
privatePem,
validateExpiration = true,
expirationWindow = TOKEN_EXPIRE_WINDOW
) {
if (validateExpiration) {
this.validateTokenExpiration(expirationWindow);
}
const sharedSecret = this.sharedSecret(privatePem);
const merchantId = this.merchantIdPeculiar(certPem);
const symmetricKey = this.symmetricKey(merchantId, sharedSecret);
const decrypted = this.decryptCiphertext(symmetricKey, this.cipherText);
return JSON.parse(decrypted);
}
/**
* Validates the expiration of a token signature.
*
* @param {number} expirationWindow - The window of time (in milliseconds) in which the signature is considered valid.
* @throws {Error} If the signature has expired.
*/
validateTokenExpiration(expirationWindow) {
// Create a new instance of the web crypto API
const webcrypto = new webCrypto.Crypto();
// Set the crypto engine to use OpenSSL
pkijs.setEngine(
"ossl-engine",
webcrypto,
new pkijs.CryptoEngine({
name: "openssl",
crypto: webcrypto,
subtle: webcrypto.subtle,
})
);
// Destructure the token attributes
const {
signature,
header,
data: encryptedPaymentData,
metadata,
} = this.tokenAttrs;
// Destructure the header attributes
const { ephemeralPublicKey, transactionId } = header;
// Convert the signature from base64 to a buffer
const cmsSignedBuffer = Buffer.from(signature, "base64");
// Parse the buffer using ASN.1 syntax
const cmsSignedASN1 = asn1js.fromBER(
new Uint8Array(cmsSignedBuffer).buffer
);
// Create a ContentInfo object from the ASN.1 schema
const cmsContentSimpl = new pkijs.ContentInfo({
schema: cmsSignedASN1.result,
});
// Create a SignedData object from the ContentInfo content
const cmsSignedData = new pkijs.SignedData({
schema: cmsContentSimpl.content,
});
// Check the certificates in the signed data
this.checkCertificates(cmsSignedData.certificates);
// Get the current date and time
const now = new Date();
// Get the signer information from the signed data
const signerInfo = cmsSignedData.signerInfos[0];
// Get the signed attributes from the signer information
const signerInfoAttrs = signerInfo.signedAttrs.attributes;
// Find the signing time attribute
const attr = signerInfoAttrs.find((x) => x.type === SIGNINGTIME_OID);
// Convert the signing time attribute to a Date object
const signedTime = new Date(attr.values[0].toDate());
// Check if the current time is past the expiration window
if (now - signedTime > expirationWindow) {
throw new Error("Signature has expired");
}
}
/**
* Checks the certificates for validity.
*
* This function checks that there are exactly two certificates provided,
* and that the first certificate has the leaf certificate OID extension,
* and the second certificate has the intermediate CA OID extension.
*
* @param {Array} certificates - An array of certificate objects to check.
* @throws {Error} If the number of certificates is not 2, or if the required extensions are not found.
*/
checkCertificates(certificates) {
// Check that there are exactly 2 certificates
if (certificates.length !== 2) {
throw new Error(
`Signature certificates number error: expected 2 but got ${certificates.length}`
);
}
// Check that the first certificate has the leaf certificate OID extension
if (
!certificates[0].extensions.find((x) => x.extnID === LEAF_CERTIFICATE_OID)
) {
throw new Error(
`Leaf certificate doesn't have extension: ${LEAF_CERTIFICATE_OID}`
);
}
// Check that the second certificate has the intermediate CA OID extension
if (
!certificates[1].extensions.find((x) => x.extnID === INTERMEDIATE_CA_OID)
) {
throw new Error(
`Intermediate certificate doesn't have extension: ${INTERMEDIATE_CA_OID}`
);
}
}
/**
* Compute shared secret using merchant private key and ephemeral public key.
* @param {string} privatePem - Merchant private key in PEM format.
* @returns {string} Shared secret.
*/
sharedSecret(privatePem) {
const prv = new ECKey(privatePem, "pem");
const publicEc = new ECKey(this.ephemeralPublicKey, "spki");
return prv.computeSecret(publicEc).toString("hex");
}
merchantIdPeculiar(cert) {
try {
// Parse the certificate using the @peculiar/x509 library
const certificate = new X509Certificate(cert);
const { issuerName, issuer } = certificate;
const certExtensions = certificate.getExtensions(MERCHANT_ID_FIELD_OID);
const { values } = certExtensions;
const { value } = certExtensions[0];
const uint8View = new Uint8Array(value);
const merchantId22 = String.fromCharCode.apply(null, uint8View);
const merchantId = merchantId22.split("@")[1];
return merchantId;
} catch (e) {
console.error("Unable to extract merchant ID from certificate", e);
}
}
/**
* Derive the symmetric key using the key derivation function described in NIST SP 800-56A, section 5.8.1
* https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-56ar.pdf
* The symmetric key is a sha256 hash that contains shared secret token plus encoding information
*/
symmetricKey(merchantId, sharedSecret) {
const KDF_ALGORITHM = "\x0did-aes256-GCM"; // The byte (0x0D) followed by the ASCII string "id-aes256-GCM". The first byte of this value is an unsigned integer that indicates the string’s length in bytes; the remaining bytes are a constiable-length string.
const KDF_PARTY_V = Buffer.from(merchantId, "hex").toString("binary"); // The SHA-256 hash of your merchant ID string literal; 32 bytes in size.
const KDF_PARTY_U = "Apple"; // The ASCII string "Apple". This value is a fixed-length string.
const KDF_INFO = KDF_ALGORITHM + KDF_PARTY_U + KDF_PARTY_V;
let hash = crypto.createHash("sha256");
hash.update(Buffer.from("000000", "hex"));
hash.update(Buffer.from("01", "hex"));
hash.update(Buffer.from(sharedSecret, "hex"));
hash.update(KDF_INFO, "binary");
return hash.digest("hex");
}
/**
* Decrypting the cipher text from the token (data in the original payment token) key using AES–256 (id-aes256-GCM 2.16.840.1.101.3.4.1.46), with an initialization vector of 16 null bytes and no associated authentication data.
*
*/
decryptCiphertext(symmetricKey, cipherText) {
const data = forge.util.decode64(cipherText);
const SYMMETRIC_KEY = forge.util.createBuffer(
Buffer.from(symmetricKey, "hex").toString("binary")
);
const IV = forge.util.createBuffer(
Buffer.from([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).toString(
"binary"
)
); // Initialization vector of 16 null bytes
const CIPHERTEXT = forge.util.createBuffer(data.slice(0, -16));
const decipher = forge.cipher.createDecipher("AES-GCM", SYMMETRIC_KEY); // Creates and returns a Decipher object that uses the given algorithm and password (key)
const tag = data.slice(-16, data.length);
decipher.start({
iv: IV,
tagLength: 128,
tag,
});
decipher.update(CIPHERTEXT);
decipher.finish();
return Buffer.from(decipher.output.toHex(), "hex").toString("utf-8");
}
}
module.exports = PaymentToken;