Skip to content

Commit 6d675e9

Browse files
Merge pull request #235 from alexanderjordanbaker/ChainCaching
Add verified chain caching
2 parents 173596f + cc5ccad commit 6d675e9

File tree

2 files changed

+102
-0
lines changed

2 files changed

+102
-0
lines changed

jws_verification.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,19 @@ import { AppTransaction, AppTransactionValidator } from './models/AppTransaction
1616

1717
const MAX_SKEW = 60000
1818

19+
const MAXIMUM_CACHE_SIZE = 32 // There are unlikely to be more than a couple keys at once
20+
const CACHE_TIME_LIMIT = 15 * 60 * 1_000 // 15 minutes
21+
22+
class CacheValue {
23+
public publicKey: KeyObject
24+
public cacheExpiry: number
25+
26+
constructor(publicKey: KeyObject, cacheExpiry: number) {
27+
this.publicKey = publicKey
28+
this.cacheExpiry = cacheExpiry
29+
}
30+
}
31+
1932
/**
2033
* A class providing utility methods for verifying and decoding App Store signed data.
2134
*
@@ -42,6 +55,7 @@ export class SignedDataVerifier {
4255
protected bundleId: string
4356
protected appAppleId?: number
4457
protected environment: Environment
58+
protected verifiedPublicKeyCache: { [index: string]: CacheValue }
4559

4660
/**
4761
*
@@ -57,6 +71,7 @@ export class SignedDataVerifier {
5771
this.bundleId = bundleId;
5872
this.environment = environment
5973
this.appAppleId = appAppleId
74+
this.verifiedPublicKeyCache = {}
6075
if (environment === Environment.PRODUCTION && appAppleId === undefined) {
6176
throw new Error("appAppleId is required when the environment is Production")
6277
}
@@ -208,6 +223,31 @@ export class SignedDataVerifier {
208223
}
209224

210225
protected async verifyCertificateChain(trustedRoots: X509Certificate[], leaf: X509Certificate, intermediate: X509Certificate, effectiveDate: Date): Promise<KeyObject> {
226+
let cacheKey = leaf.toString() + intermediate.toString()
227+
if (this.enableOnlineChecks) {
228+
if (cacheKey in this.verifiedPublicKeyCache) {
229+
if (this.verifiedPublicKeyCache[cacheKey].cacheExpiry > new Date().getTime()) {
230+
return this.verifiedPublicKeyCache[cacheKey].publicKey
231+
}
232+
}
233+
}
234+
235+
let publicKey = await this.verifyCertificateChainWithoutCaching(trustedRoots, leaf, intermediate, effectiveDate)
236+
237+
if (this.enableOnlineChecks) {
238+
this.verifiedPublicKeyCache[cacheKey] = new CacheValue(leaf.publicKey, new Date().getTime() + CACHE_TIME_LIMIT)
239+
if (Object.keys(this.verifiedPublicKeyCache).length > MAXIMUM_CACHE_SIZE) {
240+
for (let key in Object.keys(this.verifiedPublicKeyCache)) {
241+
if (this.verifiedPublicKeyCache[key].cacheExpiry < new Date().getTime()) {
242+
delete this.verifiedPublicKeyCache[key]
243+
}
244+
}
245+
}
246+
}
247+
return publicKey
248+
}
249+
250+
protected async verifyCertificateChainWithoutCaching(trustedRoots: X509Certificate[], leaf: X509Certificate, intermediate: X509Certificate, effectiveDate: Date): Promise<KeyObject> {
211251
let validity = false
212252
let rootCert
213253
for (const root of trustedRoots) {

tests/unit-tests/jws_verification.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,18 @@ const REAL_APPLE_INTERMEDIATE_BASE64_ENCODED = "MIIDFjCCApygAwIBAgIUIsGhRwp0c2nv
2121
const REAL_APPLE_SIGNING_CERTIFICATE_BASE64_ENCODED = "MIIEMDCCA7agAwIBAgIQaPoPldvpSoEH0lBrjDPv9jAKBggqhkjOPQQDAzB1MUQwQgYDVQQDDDtBcHBsZSBXb3JsZHdpZGUgRGV2ZWxvcGVyIFJlbGF0aW9ucyBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTELMAkGA1UECwwCRzYxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTIxMDgyNTAyNTAzNFoXDTIzMDkyNDAyNTAzM1owgZIxQDA+BgNVBAMMN1Byb2QgRUNDIE1hYyBBcHAgU3RvcmUgYW5kIGlUdW5lcyBTdG9yZSBSZWNlaXB0IFNpZ25pbmcxLDAqBgNVBAsMI0FwcGxlIFdvcmxkd2lkZSBEZXZlbG9wZXIgUmVsYXRpb25zMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABOoTcaPcpeipNL9eQ06tCu7pUcwdCXdN8vGqaUjd58Z8tLxiUC0dBeA+euMYggh1/5iAk+FMxUFmA2a1r4aCZ8SjggIIMIICBDAMBgNVHRMBAf8EAjAAMB8GA1UdIwQYMBaAFD8vlCNR01DJmig97bB85c+lkGKZMHAGCCsGAQUFBwEBBGQwYjAtBggrBgEFBQcwAoYhaHR0cDovL2NlcnRzLmFwcGxlLmNvbS93d2RyZzYuZGVyMDEGCCsGAQUFBzABhiVodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLXd3ZHJnNjAyMIIBHgYDVR0gBIIBFTCCAREwggENBgoqhkiG92NkBQYBMIH+MIHDBggrBgEFBQcCAjCBtgyBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMDYGCCsGAQUFBwIBFipodHRwOi8vd3d3LmFwcGxlLmNvbS9jZXJ0aWZpY2F0ZWF1dGhvcml0eS8wHQYDVR0OBBYEFCOCmMBq//1L5imvVmqX1oCYeqrMMA4GA1UdDwEB/wQEAwIHgDAQBgoqhkiG92NkBgsBBAIFADAKBggqhkjOPQQDAwNoADBlAjEAl4JB9GJHixP2nuibyU1k3wri5psGIxPME05sFKq7hQuzvbeyBu82FozzxmbzpogoAjBLSFl0dZWIYl2ejPV+Di5fBnKPu8mymBQtoE/H2bES0qAs8bNueU3CBjjh1lwnDsI=";
2222

2323
const EFFECTIVE_DATE = new Date(1681312846000); // April 2023
24+
25+
const CLOCK_DATE = 41231
2426
class SignedJWTVerifierTest extends SignedDataVerifier {
2527
effectiveDate = EFFECTIVE_DATE
2628
async testVerifyCertificateChain(trustedRoots: X509Certificate[], leaf: string, intermediate: string): Promise<KeyObject> {
2729
return await this.verifyCertificateChain(trustedRoots, new X509Certificate(Buffer.from(leaf, 'base64')), new X509Certificate(Buffer.from(intermediate, 'base64')), this.effectiveDate)
2830
}
2931

32+
public async verifyCertificateChainWithoutCaching(trustedRoots: X509Certificate[], leaf: X509Certificate, intermediate: X509Certificate, effectiveDate: Date): Promise<KeyObject> {
33+
return await super.verifyCertificateChainWithoutCaching(trustedRoots, leaf, intermediate, effectiveDate)
34+
}
35+
3036
getRootCertificates() {
3137
return this.rootCertificates
3238
}
@@ -113,6 +119,62 @@ describe("Chain Verification Checks", () => {
113119
}
114120
assert(false)
115121
})
122+
123+
it('should cache OCSP responses', async () => {
124+
jest.useFakeTimers()
125+
jest.setSystemTime(CLOCK_DATE)
126+
const verifier = new SignedJWTVerifierTest([Buffer.from(ROOT_CA_BASE64_ENCODED, 'base64')], true, Environment.PRODUCTION, "com.example", 1234);
127+
let spy = jest.spyOn(verifier, 'verifyCertificateChainWithoutCaching').mockImplementation((_, _2, _3, _4) => Promise.resolve(new X509Certificate(Buffer.from(LEAF_CERT_BASE64_ENCODED, 'base64')).publicKey));
128+
await verifier.testVerifyCertificateChain(verifier.getRootCertificates(), LEAF_CERT_BASE64_ENCODED, INTERMEDIATE_CA_BASE64_ENCODED)
129+
expect(spy).toHaveBeenCalledTimes(1);
130+
jest.setSystemTime(CLOCK_DATE + 1_000) // 1 second
131+
await verifier.testVerifyCertificateChain(verifier.getRootCertificates(), LEAF_CERT_BASE64_ENCODED, INTERMEDIATE_CA_BASE64_ENCODED)
132+
expect(spy).toHaveBeenCalledTimes(1);
133+
jest.runOnlyPendingTimers()
134+
jest.useRealTimers()
135+
})
136+
137+
it('should cache OCSP responses for a limited time', async () => {
138+
jest.useFakeTimers()
139+
jest.setSystemTime(CLOCK_DATE)
140+
const verifier = new SignedJWTVerifierTest([Buffer.from(ROOT_CA_BASE64_ENCODED, 'base64')], true, Environment.PRODUCTION, "com.example", 1234);
141+
let spy = jest.spyOn(verifier, 'verifyCertificateChainWithoutCaching').mockImplementation((_, _2, _3, _4) => Promise.resolve(new X509Certificate(Buffer.from(LEAF_CERT_BASE64_ENCODED, 'base64')).publicKey));
142+
await verifier.testVerifyCertificateChain(verifier.getRootCertificates(), LEAF_CERT_BASE64_ENCODED, INTERMEDIATE_CA_BASE64_ENCODED)
143+
expect(spy).toHaveBeenCalledTimes(1);
144+
jest.setSystemTime(CLOCK_DATE + 15 * 60 * 1_000) // 15 minutes
145+
await verifier.testVerifyCertificateChain(verifier.getRootCertificates(), LEAF_CERT_BASE64_ENCODED, INTERMEDIATE_CA_BASE64_ENCODED)
146+
expect(spy).toHaveBeenCalledTimes(2);
147+
jest.runOnlyPendingTimers()
148+
jest.useRealTimers()
149+
})
150+
151+
it('should not return cached OCSP responses for a different chain', async () => {
152+
jest.useFakeTimers()
153+
jest.setSystemTime(CLOCK_DATE)
154+
const verifier = new SignedJWTVerifierTest([Buffer.from(ROOT_CA_BASE64_ENCODED, 'base64')], true, Environment.PRODUCTION, "com.example", 1234);
155+
let spy = jest.spyOn(verifier, 'verifyCertificateChainWithoutCaching').mockImplementation((_, _2, _3, _4) => Promise.resolve(new X509Certificate(Buffer.from(LEAF_CERT_BASE64_ENCODED, 'base64')).publicKey));
156+
await verifier.testVerifyCertificateChain(verifier.getRootCertificates(), LEAF_CERT_BASE64_ENCODED, INTERMEDIATE_CA_BASE64_ENCODED)
157+
expect(spy).toHaveBeenCalledTimes(1);
158+
jest.setSystemTime(CLOCK_DATE + 15 * 60 * 1_000) // 15 minutes
159+
await verifier.testVerifyCertificateChain(verifier.getRootCertificates(), REAL_APPLE_SIGNING_CERTIFICATE_BASE64_ENCODED, REAL_APPLE_INTERMEDIATE_BASE64_ENCODED)
160+
expect(spy).toHaveBeenCalledTimes(2);
161+
jest.runOnlyPendingTimers()
162+
jest.useRealTimers()
163+
})
164+
165+
it('should not return cached OCSP responses for a slightly different chain', async () => {
166+
jest.useFakeTimers()
167+
jest.setSystemTime(CLOCK_DATE)
168+
const verifier = new SignedJWTVerifierTest([Buffer.from(ROOT_CA_BASE64_ENCODED, 'base64')], true, Environment.PRODUCTION, "com.example", 1234);
169+
let spy = jest.spyOn(verifier, 'verifyCertificateChainWithoutCaching').mockImplementation((_, _2, _3, _4) => Promise.resolve(new X509Certificate(Buffer.from(LEAF_CERT_BASE64_ENCODED, 'base64')).publicKey));
170+
await verifier.testVerifyCertificateChain(verifier.getRootCertificates(), LEAF_CERT_BASE64_ENCODED, INTERMEDIATE_CA_BASE64_ENCODED)
171+
expect(spy).toHaveBeenCalledTimes(1);
172+
jest.setSystemTime(CLOCK_DATE + 15 * 60 * 1_000) // 15 minutes
173+
await verifier.testVerifyCertificateChain(verifier.getRootCertificates(), LEAF_CERT_BASE64_ENCODED, REAL_APPLE_INTERMEDIATE_BASE64_ENCODED)
174+
expect(spy).toHaveBeenCalledTimes(2);
175+
jest.runOnlyPendingTimers()
176+
jest.useRealTimers()
177+
})
116178
})
117179

118180
describe("Decoding checks", () => {

0 commit comments

Comments
 (0)