From edcd91a205eccafacfbd54576b736c1347ed0835 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Sat, 5 Sep 2026 19:19:54 +0530 Subject: [PATCH] Fix OCSP date validation Fixes #447 --- jws_verification.ts | 24 +++- tests/unit-tests/ocsp_verification.test.ts | 143 +++++++++++++++++++++ 2 files changed, 160 insertions(+), 7 deletions(-) create mode 100644 tests/unit-tests/ocsp_verification.test.ts diff --git a/jws_verification.ts b/jws_verification.ts index 07b9ca8..f64169b 100644 --- a/jws_verification.ts +++ b/jws_verification.ts @@ -399,9 +399,11 @@ export class SignedDataVerifier { } // Validate contents const issueDate = this.parseX509Date(singleResponse.thisupdate) + // Require nextUpdate to bound the validity of the OCSP response. const nextDate = this.parseX509Date(singleResponse.nextupdate) + const now = Date.now() - if (singleResponse.status.status !== 'good' || new Date().getTime() + MAX_SKEW < issueDate.getTime() || nextDate.getTime() < new Date().getTime() - MAX_SKEW) { + if (singleResponse.status.status !== 'good' || now + MAX_SKEW < issueDate.getTime() || nextDate.getTime() < now - MAX_SKEW) { throw new VerificationException(VerificationStatus.FAILURE) } // Success @@ -417,11 +419,19 @@ export class SignedDataVerifier { } } - private parseX509Date(date: string) { - return new Date(date.replace( - /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)$/, - '$4:$5:$6 $2/$3/$1' - )); + private parseX509Date(date: unknown): Date { + // RFC 6960 requires the RFC 5280 GeneralizedTime format: YYYYMMDDHHMMSSZ. + const match = typeof date === 'string' ? /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(date) : null + if (!match || match[0] !== date) { + throw new VerificationException(VerificationStatus.FAILURE) + } + const isoDate = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}.000Z` + const parsedDate = new Date(isoDate) + // Reject invalid timestamps and calendar values that Date would normalize. + if (!Number.isFinite(parsedDate.getTime()) || parsedDate.toISOString() !== isoDate) { + throw new VerificationException(VerificationStatus.FAILURE) + } + return parsedDate } private extractSignedDate(decodedJWT: DecodedSignedData): Date { @@ -449,4 +459,4 @@ export class VerificationException extends Error { this.status = status this.cause = cause } -} \ No newline at end of file +} diff --git a/tests/unit-tests/ocsp_verification.test.ts b/tests/unit-tests/ocsp_verification.test.ts new file mode 100644 index 0000000..75c0980 --- /dev/null +++ b/tests/unit-tests/ocsp_verification.test.ts @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Apple Inc. Licensed under MIT License. + +import { X509Certificate } from 'crypto'; +import { KEYUTIL, KJUR } from 'jsrsasign'; +import * as nodeFetch from 'node-fetch'; +import { SignedDataVerifier, VerificationException, VerificationStatus } from '../../jws_verification'; +import { Environment } from '../../models/Environment'; + +class OCSPVerifierTest extends SignedDataVerifier { + public async checkOCSPStatus(cert: X509Certificate, issuer: X509Certificate): Promise { + return super.checkOCSPStatus(cert, issuer) + } +} + +describe('OCSP date parsing', () => { + const verifier = new SignedDataVerifier([], true, Environment.SANDBOX, 'com.example') + + it.each([ + ['20260905033451Z', '2026-09-05T03:34:51.000Z'], + ['20240229000000Z', '2024-02-29T00:00:00.000Z'], + ['20000229000000Z', '2000-02-29T00:00:00.000Z'], + ['00990101000000Z', '0099-01-01T00:00:00.000Z'] + ])('should parse %s in UTC', (input, expected) => { + expect(verifier['parseX509Date'](input).toISOString()).toBe(expected) + }) + + it.each([ + undefined, null, 20260905033451, '', 'invalid', + '20260905033451', '20260905033451+0000', '20260905033451.123Z', + '202609050334Z', '260905033451Z', '20260905033451Z\n', + '20260005033451Z', '20261305033451Z', '20260900033451Z', + '20260431033451Z', '20260229033451Z', '21000229000000Z', + '20260905240000Z', '20260905036000Z', '20260905033460Z' + ])('should reject invalid GeneralizedTime %p', input => { + expect(() => verifier['parseX509Date'](input)).toThrow(VerificationException) + expect(() => verifier['parseX509Date'](input)).toThrow( + expect.objectContaining({ status: VerificationStatus.FAILURE }) + ) + }) +}) + +describe('OCSP response freshness', () => { + const now = new Date('2026-09-05T12:00:00Z') + const responderKeys = KEYUTIL.generateKeypair('EC', 'secp256r1') + let issuer: X509Certificate + let leaf: X509Certificate + let responderPEM: string + let verifier: OCSPVerifierTest + + beforeAll(() => { + const issuerKeys = KEYUTIL.generateKeypair('EC', 'secp256r1') + const leafKeys = KEYUTIL.generateKeypair('EC', 'secp256r1') + const common = { + sigalg: 'SHA256withECDSA', + issuer: { str: '/CN=OCSP Test Issuer' }, + notbefore: '20200101000000Z', + notafter: '20400101000000Z', + cakey: issuerKeys.prvKeyObj + } + issuer = new X509Certificate(new KJUR.asn1.x509.Certificate({ + ...common, + serial: { int: 1 }, + subject: { str: '/CN=OCSP Test Issuer' }, + sbjpubkey: issuerKeys.pubKeyObj, + ext: [{ extname: 'basicConstraints', cA: true }] + }).getPEM()) + leaf = new X509Certificate(new KJUR.asn1.x509.Certificate({ + ...common, + serial: { int: 2 }, + subject: { str: '/CN=OCSP Test Leaf' }, + sbjpubkey: leafKeys.pubKeyObj, + ext: [{ extname: 'authorityInfoAccess', array: [{ ocsp: 'http://ocsp.example.test' }] }] + }).getPEM()) + responderPEM = new KJUR.asn1.x509.Certificate({ + ...common, + serial: { int: 3 }, + subject: { str: '/CN=OCSP Test Responder' }, + sbjpubkey: responderKeys.pubKeyObj, + ext: [{ extname: 'extKeyUsage', array: ['ocspSigning'] }] + }).getPEM() + }) + + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(now) + jest.spyOn(nodeFetch, 'default') + verifier = new OCSPVerifierTest([], true, Environment.SANDBOX, 'com.example') + }) + + afterEach(() => { + jest.useRealTimers() + jest.restoreAllMocks() + }) + + function mockResponse(thisupdate: string, nextupdate?: string, status = 'good') { + // Exercise real ASN.1 decoding, responder authorization, and signature verification. + const response = new (KJUR.asn1.ocsp as any).OCSPResponse({ + resstatus: 0, + restype: 'ocspBasic', + respid: { name: { str: '/CN=OCSP Test Responder' } }, + prodat: '20260905120000Z', + array: [{ + certid: { issuerCert: issuer.toString(), subjectCert: leaf.toString(), alg: 'sha256' }, + status: { status, time: '20260901000000Z' }, + thisupdate, + nextupdate + }], + sigalg: 'SHA256withECDSA', + reskey: responderKeys.prvKeyObj, + certs: [responderPEM] + }) + jest.mocked(nodeFetch.default).mockResolvedValue(new nodeFetch.Response(Buffer.from(response.tohex(), 'hex'))) + } + + it.each([ + ['20260905110000Z', '20260905130000Z'], + ['20260905120100Z', '20260905130000Z'], + ['20260905110000Z', '20260905115900Z'] + ])('should accept good responses within clock skew (%s, %s)', async (thisupdate, nextupdate) => { + mockResponse(thisupdate, nextupdate) + await expect(verifier.checkOCSPStatus(leaf, issuer)).resolves.toBeUndefined() + }) + + it.each([ + ['expired', '20260905110000Z', '20260905115859Z'], + ['future-dated', '20260905120101Z', '20260905130000Z'], + ['invalid thisUpdate', '20260230000000Z', '20260905130000Z'], + ['invalid nextUpdate', '20260905110000Z', '20260931000000Z'], + ['missing nextUpdate', '20260905110000Z', undefined] + ])('should reject %s responses', async (_, thisupdate, nextupdate) => { + mockResponse(thisupdate as string, nextupdate) + await expect(verifier.checkOCSPStatus(leaf, issuer)).rejects.toMatchObject({ + status: VerificationStatus.FAILURE + }) + }) + + it.each(['revoked', 'unknown'])('should reject %s status with fresh dates', async status => { + mockResponse('20260905110000Z', '20260905130000Z', status) + await expect(verifier.checkOCSPStatus(leaf, issuer)).rejects.toMatchObject({ + status: VerificationStatus.FAILURE + }) + }) +})