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
24 changes: 17 additions & 7 deletions jws_verification.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -449,4 +459,4 @@ export class VerificationException extends Error {
this.status = status
this.cause = cause
}
}
}
143 changes: 143 additions & 0 deletions tests/unit-tests/ocsp_verification.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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
})
})
})