From d9af6def90a10d079d4f8a10b32a84ac5796a6d8 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 26 Feb 2026 02:40:23 +0000 Subject: [PATCH] Implement Apple JWS notification verification - Added `backend/payment-service/src/utils/apple-cert-verifier.js` to handle JWS signature and certificate chain verification. - Added Apple Root CA G3 certificate at `backend/payment-service/src/certs/AppleRootCA-G3.pem`. - Updated `backend/payment-service/src/routes/apple-webhook.js` to use the new verification logic. - Added `backend/payment-service/tests/test-apple-verifier.js` for unit testing the verification logic using self-signed certs. - Verified that the implementation correctly validates chains and signatures. Co-authored-by: singhaditya21 <53948039+singhaditya21@users.noreply.github.com> --- .../src/certs/AppleRootCA-G3.pem | 15 ++ .../src/routes/apple-webhook.js | 27 +-- .../src/utils/apple-cert-verifier.js | 109 ++++++++++ .../tests/test-apple-verifier.js | 197 ++++++++++++++++++ 4 files changed, 328 insertions(+), 20 deletions(-) create mode 100644 backend/payment-service/src/certs/AppleRootCA-G3.pem create mode 100644 backend/payment-service/src/utils/apple-cert-verifier.js create mode 100644 backend/payment-service/tests/test-apple-verifier.js diff --git a/backend/payment-service/src/certs/AppleRootCA-G3.pem b/backend/payment-service/src/certs/AppleRootCA-G3.pem new file mode 100644 index 0000000..561fc80 --- /dev/null +++ b/backend/payment-service/src/certs/AppleRootCA-G3.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwS +QXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9u +IEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcN +MTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBS +b290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9y +aXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49 +AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtf +TjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517 +IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySr +MA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gA +MGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4 +at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM +6BgD56KyKA== +-----END CERTIFICATE----- diff --git a/backend/payment-service/src/routes/apple-webhook.js b/backend/payment-service/src/routes/apple-webhook.js index e6e7893..45bae49 100644 --- a/backend/payment-service/src/routes/apple-webhook.js +++ b/backend/payment-service/src/routes/apple-webhook.js @@ -2,23 +2,8 @@ * Apple App Store Server Notifications V2 webhook handler */ const express = require('express'); -const jwt = require('jsonwebtoken'); const router = express.Router(); - -/** - * Verify Apple's JWS signature - * In production, implement proper JWS verification - */ -function verifyAppleJWS(signedPayload) { - try { - // This is simplified - in production, verify using Apple's public key - const decoded = jwt.decode(signedPayload, { complete: true }); - return decoded.payload; - } catch (error) { - console.error('JWS verification error:', error); - return null; - } -} +const { verifyAppleJWS } = require('../utils/apple-cert-verifier'); /** * Update subscription status in database @@ -55,10 +40,12 @@ router.post('/', async (req, res) => { } // Verify and decode the payload - const payload = verifyAppleJWS(signedPayload); - - if (!payload) { - return res.status(400).json({ error: 'Invalid signature' }); + let payload; + try { + payload = verifyAppleJWS(signedPayload); + } catch (error) { + console.error('JWS verification error:', error.message); + return res.status(400).json({ error: 'Invalid signature', details: error.message }); } const { notificationType, subtype, data } = payload; diff --git a/backend/payment-service/src/utils/apple-cert-verifier.js b/backend/payment-service/src/utils/apple-cert-verifier.js new file mode 100644 index 0000000..4e707a6 --- /dev/null +++ b/backend/payment-service/src/utils/apple-cert-verifier.js @@ -0,0 +1,109 @@ +const jwt = require('jsonwebtoken'); +const { X509Certificate } = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +// Load Apple Root CA +const APPLE_ROOT_CA_PATH = path.join(__dirname, '../certs/AppleRootCA-G3.pem'); +let APPLE_ROOT_CA_CERT; + +try { + if (fs.existsSync(APPLE_ROOT_CA_PATH)) { + const fileContent = fs.readFileSync(APPLE_ROOT_CA_PATH); + APPLE_ROOT_CA_CERT = new X509Certificate(fileContent); + } else { + console.warn(`Apple Root CA not found at ${APPLE_ROOT_CA_PATH}`); + } +} catch (error) { + console.error('Failed to load Apple Root CA:', error); +} + +/** + * Verify Apple JWS signature and certificate chain + * @param {string} token - The JWS token (signedPayload) + * @param {X509Certificate} [trustedRoot] - Optional trusted root certificate (defaults to Apple Root CA) + * @returns {object} - The decoded payload + * @throws {Error} - If verification fails + */ +function verifyAppleJWS(token, trustedRoot = APPLE_ROOT_CA_CERT) { + if (!token) { + throw new Error('Missing token'); + } + + if (!trustedRoot) { + throw new Error('Trusted Root CA is not loaded or provided'); + } + + // Decode header to get x5c + const decoded = jwt.decode(token, { complete: true }); + + if (!decoded || !decoded.header || !decoded.header.x5c) { + throw new Error('Invalid JWS: Missing header or x5c'); + } + + const { x5c, alg } = decoded.header; + + if (alg !== 'ES256') { + throw new Error(`Invalid algorithm: ${alg}. Expected ES256.`); + } + + if (!Array.isArray(x5c) || x5c.length === 0) { + throw new Error('Invalid x5c: Empty or not an array'); + } + + // Parse certificates + let certs; + try { + certs = x5c.map(c => new X509Certificate(Buffer.from(c, 'base64'))); + } catch (e) { + throw new Error('Failed to parse x5c certificates: ' + e.message); + } + + // Verify certificate chain + const now = new Date(); + + for (let i = 0; i < certs.length; i++) { + const cert = certs[i]; + + // Check validity period + if (new Date(cert.validFrom) > now || new Date(cert.validTo) < now) { + throw new Error(`Certificate at index ${i} is expired or not yet valid`); + } + + // Verify chain link + if (i < certs.length - 1) { + const issuer = certs[i + 1]; + if (!cert.checkIssued(issuer)) { + throw new Error(`Certificate at index ${i} is not issued by certificate at index ${i + 1}`); + } + if (!cert.verify(issuer.publicKey)) { + throw new Error(`Certificate signature verification failed at index ${i}`); + } + } else { + // Verify the last certificate against the trusted root + if (!cert.checkIssued(trustedRoot)) { + throw new Error('Certificate chain is not trusted by the Root CA'); + } + if (!cert.verify(trustedRoot.publicKey)) { + throw new Error('Certificate chain signature verification failed against Root CA'); + } + } + } + + // Verify JWS signature using the leaf certificate (first one) + const leafCert = certs[0]; + const publicKey = leafCert.publicKey; + + try { + // verify function returns the payload if successful + return jwt.verify(token, publicKey, { algorithms: ['ES256'] }); + } catch (err) { + throw new Error('JWS signature verification failed: ' + err.message); + } +} + +module.exports = { + verifyAppleJWS, + // Export for testing purposes if needed + getAppleRootCA: () => APPLE_ROOT_CA_CERT +}; diff --git a/backend/payment-service/tests/test-apple-verifier.js b/backend/payment-service/tests/test-apple-verifier.js new file mode 100644 index 0000000..51d7847 --- /dev/null +++ b/backend/payment-service/tests/test-apple-verifier.js @@ -0,0 +1,197 @@ +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const jwt = require('jsonwebtoken'); +const { X509Certificate } = require('crypto'); +const { verifyAppleJWS, getAppleRootCA } = require('../src/utils/apple-cert-verifier'); + +const TEMP_DIR = path.join(__dirname, 'temp_certs'); + +function generateKeysAndCerts() { + console.log('Generating test keys and certificates...'); + + if (fs.existsSync(TEMP_DIR)) { + fs.rmSync(TEMP_DIR, { recursive: true, force: true }); + } + fs.mkdirSync(TEMP_DIR); + + try { + // 1. Generate Root CA Key and Cert + // Apple uses ECDSA P-256 + execSync(`openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout "${TEMP_DIR}/root.key" -out "${TEMP_DIR}/root.pem" -days 365 -nodes -subj "/CN=Test Root CA"`); + + // 2. Generate Intermediate Key and CSR + execSync(`openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout "${TEMP_DIR}/intermediate.key" -out "${TEMP_DIR}/intermediate.csr" -nodes -subj "/CN=Test Intermediate CA"`); + + // 3. Sign Intermediate with Root + // Create extensions file for CA usage + const v3Config = ` +basicConstraints = CA:TRUE +keyUsage = digitalSignature, keyCertSign, cRLSign + `; + const extPath = path.join(TEMP_DIR, 'v3.ext'); + fs.writeFileSync(extPath, v3Config); + + // Sign intermediate + execSync(`openssl x509 -req -in "${TEMP_DIR}/intermediate.csr" -CA "${TEMP_DIR}/root.pem" -CAkey "${TEMP_DIR}/root.key" -CAcreateserial -out "${TEMP_DIR}/intermediate.pem" -days 365 -extfile "${extPath}"`); + + // 4. Generate Leaf Key and CSR + execSync(`openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout "${TEMP_DIR}/leaf.key" -out "${TEMP_DIR}/leaf.csr" -nodes -subj "/CN=Test Leaf"`); + + // 5. Sign Leaf with Intermediate + // Leaf extensions + const leafConfig = ` +basicConstraints = CA:FALSE +keyUsage = digitalSignature + `; + const leafExtPath = path.join(TEMP_DIR, 'leaf.ext'); + fs.writeFileSync(leafExtPath, leafConfig); + + execSync(`openssl x509 -req -in "${TEMP_DIR}/leaf.csr" -CA "${TEMP_DIR}/intermediate.pem" -CAkey "${TEMP_DIR}/intermediate.key" -CAcreateserial -out "${TEMP_DIR}/leaf.pem" -days 365 -extfile "${leafExtPath}"`); + + // Read files + const rootCert = new X509Certificate(fs.readFileSync(path.join(TEMP_DIR, 'root.pem'))); + const intermediateCertDer = fs.readFileSync(path.join(TEMP_DIR, 'intermediate.pem')); + const leafCertDer = fs.readFileSync(path.join(TEMP_DIR, 'leaf.pem')); + const leafKey = fs.readFileSync(path.join(TEMP_DIR, 'leaf.key')); + + // Helper to convert PEM to base64 string (strip header/footer/newlines) + // Actually, openssl output is PEM (base64 with headers). + // JWS x5c expects base64 string of DER. + // Wait, PEM is base64 of DER but with headers. + // So I can just strip headers/footers and newlines. + + const pemToDerBase64 = (pemBuffer) => { + const pem = pemBuffer.toString(); + return pem + .replace(/-----BEGIN CERTIFICATE-----/g, '') + .replace(/-----END CERTIFICATE-----/g, '') + .replace(/[\r\n\s]/g, ''); + }; + + const x5c = [pemToDerBase64(leafCertDer), pemToDerBase64(intermediateCertDer)]; + + return { rootCert, x5c, leafKey }; + + } catch (e) { + console.error('Error generating certificates:', e.message); + if (e.stderr) console.error('OpenSSL stderr:', e.stderr.toString()); + throw e; + } +} + +async function runTests() { + try { + // Test 0: Verify Apple Root CA is loaded + console.log('Test 0: Verifying Apple Root CA is loaded...'); + const appleRoot = getAppleRootCA(); + if (appleRoot && appleRoot.subject && appleRoot.subject.includes('Apple Root CA - G3')) { + console.log('✅ Passed: Apple Root CA loaded successfully'); + } else { + console.error('❌ Failed: Apple Root CA not loaded or incorrect'); + process.exit(1); + } + + const { rootCert, x5c, leafKey } = generateKeysAndCerts(); + + console.log('Running verifyAppleJWS tests...'); + + // 1. Valid Signature + const payload = { + notificationType: 'TEST', + subtype: 'UNIT_TEST', + data: { transactionId: '123' }, + exp: Math.floor(Date.now() / 1000) + 3600 + }; + + const token = jwt.sign(payload, leafKey, { + algorithm: 'ES256', + header: { x5c, alg: 'ES256' } + }); + + console.log('Test 1: Valid signature and chain'); + const decoded = verifyAppleJWS(token, rootCert); + if (decoded.notificationType === 'TEST') { + console.log('✅ Passed'); + } else { + console.error('❌ Failed: Payload mismatch'); + process.exit(1); + } + + // 2. Invalid Signature (Tampered Payload) + console.log('Test 2: Tampered payload'); + const parts = token.split('.'); + // Tamper with payload + const tamperedPayload = Buffer.from(JSON.stringify({ ...payload, notificationType: 'TAMPERED' })).toString('base64').replace(/=/g, ''); + const tamperedToken = `${parts[0]}.${tamperedPayload}.${parts[2]}`; + + try { + verifyAppleJWS(tamperedToken, rootCert); + console.error('❌ Failed: Should have thrown error for invalid signature'); + process.exit(1); + } catch (e) { + console.log('✅ Passed: Caught error:', e.message); + } + + // 3. Untrusted Root + console.log('Test 3: Untrusted root'); + // Create another random root + const otherRootKeyPath = path.join(TEMP_DIR, 'other.key'); + const otherRootCertPath = path.join(TEMP_DIR, 'other.pem'); + execSync(`openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout "${otherRootKeyPath}" -out "${otherRootCertPath}" -days 365 -nodes -subj "/CN=Other Root"`); + const otherRootCert = new X509Certificate(fs.readFileSync(otherRootCertPath)); + + try { + verifyAppleJWS(token, otherRootCert); // Verify valid token against WRONG root + console.error('❌ Failed: Should have thrown error for untrusted root'); + process.exit(1); + } catch (e) { + console.log('✅ Passed: Caught error:', e.message); + } + + // 4. Broken Chain (Leaf signed by someone else, not intermediate) + console.log('Test 4: Broken chain'); + // Generate a new intermediate that didn't sign the leaf + const badInterPath = path.join(TEMP_DIR, 'bad_inter.pem'); + const badInterKey = path.join(TEMP_DIR, 'bad_inter.key'); + execSync(`openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -keyout "${badInterKey}" -out "${badInterPath}" -days 365 -nodes -subj "/CN=Bad Intermediate"`); + const badInterDer = fs.readFileSync(badInterPath); + + // Construct token with original leaf but swapped intermediate in x5c + const badX5c = [x5c[0], pemToDerBase64(badInterDer)]; + const badChainToken = jwt.sign(payload, leafKey, { + algorithm: 'ES256', + header: { x5c: badX5c, alg: 'ES256' } + }); + + try { + verifyAppleJWS(badChainToken, rootCert); + console.error('❌ Failed: Should have thrown error for broken chain'); + process.exit(1); + } catch (e) { + console.log('✅ Passed: Caught error:', e.message); + } + + console.log('All tests passed!'); + + } catch (error) { + console.error('Test script failed:', error); + process.exit(1); + } finally { + // cleanup + if (fs.existsSync(TEMP_DIR)) { + fs.rmSync(TEMP_DIR, { recursive: true, force: true }); + } + } +} + +// Helper needed inside runTests +const pemToDerBase64 = (pemBuffer) => { + const pem = pemBuffer.toString(); + return pem + .replace(/-----BEGIN CERTIFICATE-----/g, '') + .replace(/-----END CERTIFICATE-----/g, '') + .replace(/[\r\n\s]/g, ''); +}; + +runTests();