Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions backend/payment-service/src/certs/AppleRootCA-G3.pem
Original file line number Diff line number Diff line change
@@ -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-----
27 changes: 7 additions & 20 deletions backend/payment-service/src/routes/apple-webhook.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
109 changes: 109 additions & 0 deletions backend/payment-service/src/utils/apple-cert-verifier.js
Original file line number Diff line number Diff line change
@@ -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
};
197 changes: 197 additions & 0 deletions backend/payment-service/tests/test-apple-verifier.js
Original file line number Diff line number Diff line change
@@ -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();