From 766acc65b1e3e07e24603599830b897500c8671b Mon Sep 17 00:00:00 2001 From: Denis Mingulov Date: Tue, 13 Jan 2026 15:43:19 +0200 Subject: [PATCH] fix: resolve OCSP 'UNKNOWN' status via robust AKI/SKI issuer matching and improved TSA status handling --- CHANGELOG.md | 6 + packages/cli/src/cli.ts | 19 +++ packages/core/src/index.ts | 4 +- packages/core/src/pdf/ltv.ts | 23 ++- packages/core/src/pki/cert-utils.ts | 104 ++++++++++++ packages/core/src/pki/ocsp-utils.ts | 71 ++++++++- packages/core/src/session.ts | 4 +- packages/core/src/tsa/response.ts | 21 ++- packages/tests/test/unit/cert-utils.test.ts | 148 +++++++++++++++++- .../test/unit/regression-ocsp-parsing.test.ts | 134 ++++++++++++++++ packages/tests/test/unit/tsa-response.test.ts | 13 +- 11 files changed, 520 insertions(+), 27 deletions(-) create mode 100644 packages/tests/test/unit/regression-ocsp-parsing.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 96d70f8..ba60ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.4] - Unreleased + +### Fixed + +- Fixed OCSP "UNKNOWN" status issues by robust issuer matching (AKI/SKI) and improved compatibility with TSA warnings. + ## [0.1.3] - 2026-01-12 ### Added diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9432376..e56945e 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -11,6 +11,7 @@ import { KNOWN_TSA_URLS, TimestampError, type HashAlgorithm, + setLogger, } from "pdf-rfc3161"; import * as pkijs from "pkijs"; @@ -108,6 +109,15 @@ program if (options.verbose) { console.log(`Read ${String(pdfBytes.length)} bytes from ${inputFile}`); console.log("Requesting timestamp from TSA..."); + + // Enable verbose logging in core library + const verboseLogger = { + debug: (msg: string, ...args: unknown[]) => console.debug(`[DEBUG] ${msg}`, ...args), + info: (msg: string, ...args: unknown[]) => console.info(`[INFO] ${msg}`, ...args), + warn: (msg: string, ...args: unknown[]) => console.warn(`[WARN] ${msg}`, ...args), + error: (msg: string, ...args: unknown[]) => console.error(`[ERROR] ${msg}`, ...args), + }; + setLogger(verboseLogger); } // Timestamp the PDF @@ -213,6 +223,15 @@ program if (cmdOptions.verbose) { console.log("Processing document..."); + + // Enable verbose logging in core library + const verboseLogger = { + debug: (msg: string, ...args: unknown[]) => console.debug(`[DEBUG] ${msg}`, ...args), + info: (msg: string, ...args: unknown[]) => console.info(`[INFO] ${msg}`, ...args), + warn: (msg: string, ...args: unknown[]) => console.warn(`[WARN] ${msg}`, ...args), + error: (msg: string, ...args: unknown[]) => console.error(`[ERROR] ${msg}`, ...args), + }; + setLogger(verboseLogger); } const result = await timestampPdfLTA({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 096d93d..7d9b212 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -210,7 +210,9 @@ export async function timestampPdf(options: TimestampOptions): Promise - c.subject.toString() === cert.issuer.toString() && - // Basic check to ensure we aren't using the cert as its own issuer (unless self-signed, but then no OCSP usually) - // Serial number check helps avoid self-match for non-root - c.serialNumber.toString() !== cert.serialNumber.toString() - ); + const issuer = findIssuer(cert, certs); if (!issuer) { + // If we can't find the issuer, we can't fetch OCSP (needs issuer hash) + // We might find it via AIA later, but for now skip + continue; + } + + // Avoid using cert as its own issuer for OCSP (unless strictly self-signed root, but OCSP typicaly for end-entity) + if (issuer.serialNumber.isEqual(cert.serialNumber)) { continue; } @@ -544,7 +543,7 @@ export async function completeLTVData( const parsed = parseOCSPResponse(response); if (parsed.certStatus !== CertificateStatus.GOOD) { errors.push( - `OCSP indicates certificate is not good (Status: ${CertificateStatus[parsed.certStatus]}), skipping for cert serial: ${cert.serialNumber.valueBlock.toString()}` + `OCSP indicates certificate is not good (Status: ${CertificateStatus[parsed.certStatus]}), skipping for cert serial: ${bytesToHex((cert.serialNumber as unknown as asn1js.Integer).valueBlock.valueHexView)}` ); continue; } @@ -565,7 +564,7 @@ export async function completeLTVData( } catch (e) { // OCSP fetch failed - log error and continue to CRL errors.push( - `Failed to fetch OCSP for certificate (Serial: ${cert.serialNumber.valueBlock.toString()}): ${e instanceof Error ? e.message : String(e)}` + `Failed to fetch OCSP for certificate (Serial: ${bytesToHex((cert.serialNumber as unknown as asn1js.Integer).valueBlock.valueHexView)}): ${e instanceof Error ? e.message : String(e)}` ); } } diff --git a/packages/core/src/pki/cert-utils.ts b/packages/core/src/pki/cert-utils.ts index 2d84dbe..f5fbb9c 100644 --- a/packages/core/src/pki/cert-utils.ts +++ b/packages/core/src/pki/cert-utils.ts @@ -1,6 +1,7 @@ import * as pkijs from "pkijs"; import * as asn1js from "asn1js"; import { getLogger } from "../utils/logger.js"; +import { bytesToHex } from "../utils.js"; /** * Authority Information Access (AIA) Extension OID @@ -74,3 +75,106 @@ export function getCaIssuers(cert: pkijs.Certificate): string[] { return urls; } + +/** + * Finds the issuer certificate for a given certificate from a list of candidates. + * Uses Authority Key Identifier (AKI) and Subject Key Identifier (SKI) if available, + * causing a more robust match than just Subject Name. + * + * @param cert - The certificate to find the issuer for + * @param candidates - List of potential issuer certificates + * @returns The issuer certificate if found, otherwise undefined + */ +export function findIssuer( + cert: pkijs.Certificate, + candidates: pkijs.Certificate[] +): pkijs.Certificate | undefined { + // 1. Filter by Issuer Name (Subject) + const nameMatches = candidates.filter((c) => c.subject.toString() === cert.issuer.toString()); + + if (nameMatches.length === 0) { + return undefined; + } + + // If only one name match, return it (most common case) + if (nameMatches.length === 1) { + return nameMatches[0]; + } + + // 2. If multiple name matches, filter by Key Identifier (AKI == SKI) + try { + const AKI_OID = "2.5.29.35"; + const SKI_OID = "2.5.29.14"; + + const akiExt = cert.extensions?.find((ext) => ext.extnID === AKI_OID); + if (akiExt?.extnValue) { + try { + const akiAsn1 = asn1js.fromBER(akiExt.extnValue.valueBlock.valueHexView); + + if (akiAsn1.result instanceof asn1js.Sequence) { + // Try pkijs parsing first + const aki = new pkijs.AuthorityKeyIdentifier({ schema: akiAsn1.result }); + let keyIdHex: string | undefined; + + if (aki.keyIdentifier) { + keyIdHex = bytesToHex(aki.keyIdentifier.valueBlock.valueHexView); + } else { + // Fallback: Manual search in sequence + if ( + "value" in akiAsn1.result.valueBlock && + Array.isArray(akiAsn1.result.valueBlock.value) + ) { + const sequenceValue = akiAsn1.result.valueBlock.value; + const ki = sequenceValue.find( + (item) => + "tagNumber" in item.idBlock && item.idBlock.tagNumber === 0 + ); + if (ki && "valueHexView" in ki.valueBlock) { + keyIdHex = bytesToHex(ki.valueBlock.valueHexView as ArrayBuffer); + } + } + } + + if (keyIdHex) { + const keyMatch = nameMatches.find((candidate) => { + const skiExt = candidate.extensions?.find( + (ext) => ext.extnID === SKI_OID + ); + if (!skiExt?.extnValue) return false; + + // Try to parse SKI as nested OctetString, fallback to raw + const skiAsn1 = asn1js.fromBER( + skiExt.extnValue.valueBlock.valueHexView + ); + let skiHex: string; + if ( + skiAsn1.offset !== -1 && + skiAsn1.result instanceof asn1js.OctetString + ) { + skiHex = bytesToHex(skiAsn1.result.valueBlock.valueHexView); + } else { + skiHex = bytesToHex(skiExt.extnValue.valueBlock.valueHexView); + } + + return skiHex === keyIdHex; + }); + + if (keyMatch) { + return keyMatch; + } + } + } + } catch (e) { + getLogger().debug( + `[Cert-Utils] AKI parsing failed: ${e instanceof Error ? e.message : String(e)}` + ); + } + } + } catch (e) { + getLogger().warn("[Cert-Utils] Error matching AKI/SKI:", e); + } + + // 3. Fallback: Return the first name match + // Ideally we might check validity dates or other factors, but first match is standard fallback + return nameMatches[0]; +} diff --git a/packages/core/src/pki/ocsp-utils.ts b/packages/core/src/pki/ocsp-utils.ts index 2082bfe..1035ec3 100644 --- a/packages/core/src/pki/ocsp-utils.ts +++ b/packages/core/src/pki/ocsp-utils.ts @@ -1,6 +1,7 @@ import * as pkijs from "pkijs"; import * as asn1js from "asn1js"; import { TimestampError, TimestampErrorCode } from "../types.js"; +import { getLogger } from "../utils/logger.js"; /** * OCSP Response Status values (RFC 6960) @@ -53,7 +54,29 @@ export function parseOCSPResponse(responseBytes: Uint8Array): ParsedOCSPResponse const ocspResponse = new pkijs.OCSPResponse({ schema: asn1.result }); // Check response status - pkijs returns an Enumerated type - const statusValue = ocspResponse.responseStatus as unknown as number; + // Check response status - pkijs returns an Enumerated type (object) + // We need to extract the actual number from it + let statusValue: number; + + const rawStatus = ocspResponse.responseStatus as unknown; + + if (typeof rawStatus === "number") { + statusValue = rawStatus; + } else if ( + rawStatus && + typeof rawStatus === "object" && + "valueBlock" in rawStatus && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + typeof (rawStatus as any).valueBlock.valueDec === "number" + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + statusValue = (rawStatus as any).valueBlock.valueDec as number; + } else { + // Fallback or error if structure is unexpected + // Defaulting to a value that isn't SUCCESSFUL (0) if we can't parse it + statusValue = OCSPResponseStatus.INTERNAL_ERROR; + } + const status = statusValue as OCSPResponseStatus; if (status !== OCSPResponseStatus.SUCCESSFUL) { @@ -103,12 +126,50 @@ export function parseOCSPResponse(responseBytes: Uint8Array): ParsedOCSPResponse // Extract certificate status let certStatus: CertificateStatus; - if (singleResponse.certStatus === null) { + + // Check if it's explicitly null or undefined (though pkijs usually returns an object) + if (singleResponse.certStatus === null || singleResponse.certStatus === undefined) { certStatus = CertificateStatus.GOOD; - } else if ("revocationTime" in singleResponse.certStatus) { - certStatus = CertificateStatus.REVOKED; } else { - certStatus = CertificateStatus.UNKNOWN; + // Log what we have to debug "Unknown" status + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const statusAsn1 = singleResponse.certStatus; + getLogger().debug( + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + `Inspecting CertStatus: type=${statusAsn1 ? (statusAsn1.constructor.name as string) : "null"}, hasIdBlock=${statusAsn1 ? String("idBlock" in statusAsn1) : "false"}, JSON=${JSON.stringify(statusAsn1, (k, v) => (k === "valueHex" || k === "valueHexView" ? "[hex]" : (v as unknown)))}` + ); + + if (statusAsn1 && "idBlock" in (statusAsn1 as object)) { + // Use ASN.1 tag number to determine status (RFC 6960) + // CertStatus ::= CHOICE { + // good [0] IMPLICIT NULL, + // revoked [1] IMPLICIT RevokedInfo, + // unknown [2] IMPLICIT UnknownInfo } + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const tagNumber = statusAsn1.idBlock.tagNumber as number; + + getLogger().debug( + `OCSP CertStatus Tag: ${String(tagNumber)} (0=Good, 1=Revoked, 2=Unknown)` + ); + + switch (tagNumber) { + case 0: + certStatus = CertificateStatus.GOOD; + break; + case 1: + certStatus = CertificateStatus.REVOKED; + break; + case 2: + certStatus = CertificateStatus.UNKNOWN; + break; + default: + certStatus = CertificateStatus.UNKNOWN; + } + } else { + // Fallback for unexpected structure + getLogger().warn(`CertStatus missing idBlock: ${JSON.stringify(statusAsn1)}`); + certStatus = CertificateStatus.UNKNOWN; + } } // Extract timestamps diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index f456da1..a41d49a 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -164,7 +164,9 @@ export class TimestampSession { // Check if TSA rejected the request if ( parsed.status !== TSAStatus.GRANTED && - parsed.status !== TSAStatus.GRANTED_WITH_MODS + parsed.status !== TSAStatus.GRANTED_WITH_MODS && + parsed.status !== TSAStatus.REVOCATION_WARNING && + parsed.status !== TSAStatus.REVOCATION_NOTIFICATION ) { throw new TimestampError( TimestampErrorCode.TSA_ERROR, diff --git a/packages/core/src/tsa/response.ts b/packages/core/src/tsa/response.ts index ba89604..db02e27 100644 --- a/packages/core/src/tsa/response.ts +++ b/packages/core/src/tsa/response.ts @@ -8,6 +8,7 @@ import { type TimestampInfo, } from "../types.js"; import { bytesToHex } from "../utils.js"; +import { getLogger } from "../utils/logger.js"; import { extractTimestampInfoFromContentInfo } from "../pki/pki-utils.js"; interface StatusInfo { @@ -222,7 +223,12 @@ export function parseTimestampResponse(responseBytes: Uint8Array): ParsedTimesta ); } - if (status !== TSAStatus.GRANTED && status !== TSAStatus.GRANTED_WITH_MODS) { + if ( + status !== TSAStatus.GRANTED && + status !== TSAStatus.GRANTED_WITH_MODS && + status !== TSAStatus.REVOCATION_WARNING && + status !== TSAStatus.REVOCATION_NOTIFICATION + ) { return { status, statusString, @@ -230,6 +236,19 @@ export function parseTimestampResponse(responseBytes: Uint8Array): ParsedTimesta }; } + if ( + status === TSAStatus.REVOCATION_WARNING || + status === TSAStatus.REVOCATION_NOTIFICATION + ) { + const statusName = + status === TSAStatus.REVOCATION_WARNING + ? "REVOCATION_WARNING" + : "REVOCATION_NOTIFICATION"; + getLogger().warn( + `TSA returned ${statusName}: ${statusString ?? "Response is valid but a revocation is pending."}` + ); + } + if (!tsResp?.timeStampToken) { const manuallyExtractedToken = tryExtractTokenFromASN1(asn1.result); if (manuallyExtractedToken) { diff --git a/packages/tests/test/unit/cert-utils.test.ts b/packages/tests/test/unit/cert-utils.test.ts index 515f225..29a2a4a 100644 --- a/packages/tests/test/unit/cert-utils.test.ts +++ b/packages/tests/test/unit/cert-utils.test.ts @@ -1,9 +1,8 @@ - import { describe, it, expect } from "vitest"; import * as pkijs from "pkijs"; import * as asn1js from "asn1js"; -import { getCaIssuers } from "../../../core/src/pki/cert-utils.js"; - +import { getCaIssuers, findIssuer } from "../../../core/src/pki/cert-utils.js"; +import { hexToBytes } from "../../../core/src/utils.js"; // Helper to construct a cert with AIA extension function createCertWithAIA(urls: string[]): pkijs.Certificate { const cert = new pkijs.Certificate(); @@ -20,12 +19,12 @@ function createCertWithAIA(urls: string[]): pkijs.Certificate { }); }); - // Create AuthorityInfoAccess syntax manually (Sequence of AccessDescriptions) + // Create AuthorityInfoAccess syntax manually (asn1js.Sequence of AccessDescriptions) const aiaSyntax = new asn1js.Sequence({ value: accessDescriptions.map(ad => ad.toSchema()) }); - // Create Extension + // Create pkijs.Extension const ext = new pkijs.Extension({ extnID: "1.3.6.1.5.5.7.1.1", // AIA extnValue: aiaSyntax.toBER(false) @@ -39,6 +38,75 @@ function createCertWithAIA(urls: string[]): pkijs.Certificate { return cert; } +// Helper to create a basic certificate with subject, issuer, and optional AKI/SKI +function createTestCert(options: { + subject: string, + issuer?: string, + ski?: string, + aki?: string, + serial?: string +}): pkijs.Certificate { + const cert = new pkijs.Certificate(); + + // Set Subject + const subject = new pkijs.RelativeDistinguishedNames({ + typesAndValues: [ + new pkijs.AttributeTypeAndValue({ + type: "2.5.4.3", // commonName + value: new asn1js.Utf8String({ value: options.subject }) + }) + ] + }); + cert.subject = subject; + + // Set Issuer + const issuer = new pkijs.RelativeDistinguishedNames({ + typesAndValues: [ + new pkijs.AttributeTypeAndValue({ + type: "2.5.4.3", // commonName + value: new asn1js.Utf8String({ value: options.issuer ?? options.subject }) + }) + ] + }); + cert.issuer = issuer; + + // Set Serial Number + if (options.serial) { + cert.serialNumber = new asn1js.Integer({ value: parseInt(options.serial) }); + } + + cert.extensions = []; + + // Add SKI + if (options.ski) { + const skiBytes = hexToBytes(options.ski); + const skiValue = new asn1js.OctetString({ + valueHex: skiBytes.buffer, + // Ensure we use a clean buffer if possible, but hexToBytes already creates one + }); + cert.extensions.push(new pkijs.Extension({ + extnID: "2.5.29.14", + extnValue: skiValue.toBER(false) + })); + } + + // Add AKI + if (options.aki) { + const akiBytes = hexToBytes(options.aki); + const aki = new pkijs.AuthorityKeyIdentifier({ + keyIdentifier: new asn1js.OctetString({ + valueHex: akiBytes.buffer + }) + }); + cert.extensions.push(new pkijs.Extension({ + extnID: "2.5.29.35", + extnValue: aki.toSchema().toBER(false) + })); + } + + return cert; +} + describe("Cert Utils", () => { describe("getCaIssuers", () => { it("should return empty array if no extensions", () => { @@ -69,4 +137,74 @@ describe("Cert Utils", () => { expect(urls).toEqual(["http://a.com/1.cer", "http://b.com/2.crt"]); }); }); + + describe("findIssuer", () => { + it("should find issuer by common name match", () => { + const issuer = createTestCert({ subject: "Root CA" }); + const child = createTestCert({ subject: "Intermediate CA", issuer: "Root CA" }); + + const result = findIssuer(child, [issuer]); + expect(result).toBe(issuer); + }); + + it("should return undefined if no issuer name matches", () => { + const notIssuer = createTestCert({ subject: "Wrong CA" }); + const child = createTestCert({ subject: "Intermediate CA", issuer: "Root CA" }); + + const result = findIssuer(child, [notIssuer]); + expect(result).toBeUndefined(); + }); + + it("should use AKI/SKI to differentiate when multiple issuers have same name", () => { + // Two roots with same name but different keys (cross-signing scenario) + const root1 = createTestCert({ subject: "Root CA", ski: "11111111", serial: "1" }); + const root2 = createTestCert({ subject: "Root CA", ski: "22222222", serial: "2" }); + + // Child points to Root CA and specifically key 2222... + const child = createTestCert({ + subject: "Intermediate CA", + issuer: "Root CA", + aki: "22222222" + }); + + const candidates = [root1, root2]; + const result = findIssuer(child, candidates); + + expect(result).toBe(root2); + expect(result?.serialNumber.valueBlock.valueDec).toBe(2); + }); + + it("should fallback to first match if AKI/SKI missing", () => { + const root1 = createTestCert({ subject: "Root CA", serial: "1" }); + const root2 = createTestCert({ subject: "Root CA", serial: "2" }); + + const child = createTestCert({ + subject: "Intermediate CA", + issuer: "Root CA" + }); + + const candidates = [root1, root2]; + const result = findIssuer(child, candidates); + + // Should pick the first one from the list + expect(result).toBe(root1); + }); + + it("should handle error in AKI/SKI matching and fallback", () => { + // Malformed AKI extension in child + const root = createTestCert({ subject: "Root CA", ski: "11111111" }); + const child = createTestCert({ subject: "Intermediate CA", issuer: "Root CA" }); + + // Sabotage extensions to cause a parse error if possible, or just missing AKI + child.extensions = [ + new pkijs.Extension({ + extnID: "2.5.29.35", + extnValue: new Uint8Array([0x00]).buffer // invalid ASN.1 + }) + ]; + + const result = findIssuer(child, [root]); + expect(result).toBe(root); // Still finds it via name + }); + }); }); diff --git a/packages/tests/test/unit/regression-ocsp-parsing.test.ts b/packages/tests/test/unit/regression-ocsp-parsing.test.ts new file mode 100644 index 0000000..e371250 --- /dev/null +++ b/packages/tests/test/unit/regression-ocsp-parsing.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { parseOCSPResponse } from "pdf-rfc3161"; +import * as pkijs from "pkijs"; +import * as asn1js from "asn1js"; + +describe("Regression: OCSP Response Parsing", () => { + // The bug was that pkijs/asn1js parses the status as an Enumerated object, + // but the code expected a number. + // + // OCSPResponse ::= SEQUENCE { + // responseStatus OCSPResponseStatus, + // responseBytes [0] EXPLICIT ResponseBytes OPTIONAL } + // + // OCSPResponseStatus ::= ENUMERATED { successful(0), ... } + + it("should correctly parse status 'successful' (0) from DER (fix validation)", () => { + // DER: SEQUENCE(0x30) len 0x03 -> ENUMERATED(0x0a) len 0x01 val 0x00 + const successfulResponse = new Uint8Array([0x30, 0x03, 0x0a, 0x01, 0x00]); + + // Before fix: Threw "OCSP responder error: Unknown error" because it misread the object as implicit number check fail + // After fix: correctly sees 0 (SUCCESSFUL), proceeds, then throws "no responseBytes" (expected) + + try { + parseOCSPResponse(successfulResponse); + } catch (error: any) { + expect(error.message).toBe("OCSP response has no responseBytes"); + } + }); + + it("should correctly parse status 'internalError' (2) from DER", () => { + // DER: SEQUENCE(0x30) len 0x03 -> ENUMERATED(0x0a) len 0x01 val 0x02 + const errorResponse = new Uint8Array([0x30, 0x03, 0x0a, 0x01, 0x02]); + + try { + parseOCSPResponse(errorResponse); + } catch (error: any) { + // Should properly identify the error code + expect(error.message).toContain("Internal Error"); + expect(error.message).toContain("code: 2"); + } + }); + + it("should correctly parse status 'revoked' (1) from DER", () => { + // Construct a revoked response using pkijs/asn1js with correct types + + // 1. Mock certStatus: [1] IMPLICIT RevokedInfo + const revokedInfo = new asn1js.Constructed({ + idBlock: { + tagClass: 3, // Context-specific + tagNumber: 1 // [1] + }, + value: [ + new asn1js.GeneralizedTime({ valueDate: new Date() }) + ] + }); + + // 2. Mock SingleResponse + const certID = new pkijs.CertID({ + hashAlgorithm: new pkijs.AlgorithmIdentifier({ + algorithmId: "1.3.14.3.2.26", // sha-1 + algorithmParams: new asn1js.Null() + }), + issuerNameHash: new asn1js.OctetString({ valueHex: new Uint8Array(20).buffer }), + issuerKeyHash: new asn1js.OctetString({ valueHex: new Uint8Array(20).buffer }), + serialNumber: new asn1js.Integer({ value: 1 }), + }); + + const singleResp = new pkijs.SingleResponse({ + certID, + certStatus: revokedInfo, + thisUpdate: new Date(), + }); + + // 3. Mock ResponseData (tbsResponseData) + // responderID CHOICE { byName [1] Name, byKey [2] KeyHash } + + const rdn = new pkijs.RelativeDistinguishedNames({ + typesAndValues: [ + new pkijs.AttributeTypeAndValue({ + type: "2.5.4.3", // CN + value: new asn1js.PrintableString({ value: "Test Responder" }) + }) + ] + }); + const responderID = new asn1js.Constructed({ + idBlock: { tagClass: 3, tagNumber: 1 }, // [1] EXPLICIT Name + value: [rdn.toSchema()] + }); + + // ResponseData expects specific fields. + // passing 'responderID' as the raw schema object directly might fail if pkijs tries to convert it AGAIN or check instance type. + // So we might need to bypass BasicOCSPResponse constructor validation by constructing ResponseData manually OR using `any` cast trick properly. + + // Let's try manual ResponseData + BasicOCSPResponse wrapper since we know the components are valid DER. + + const responseDataRaw = new asn1js.Sequence({ + value: [ + new asn1js.Constructed({ + idBlock: { tagClass: 3, tagNumber: 0 }, + value: [new asn1js.Integer({ value: 0 })] // Version 0 (v1) + }), + responderID, + new asn1js.GeneralizedTime({ valueDate: new Date() }), + new asn1js.Sequence({ value: [singleResp.toSchema()] }) + ] + }); + + // BasicOCSPResponse + const basicOCSPRaw = new asn1js.Sequence({ + value: [ + responseDataRaw, + new pkijs.AlgorithmIdentifier({ algorithmId: "1.2.840.113549.1.1.11" }).toSchema(), + new asn1js.BitString({ valueHex: new Uint8Array(128).buffer }) + ] + }); + + // OCSPResponse + const responseBytes = new pkijs.ResponseBytes({ + responseType: "1.3.6.1.5.5.7.48.1.1", // id-pkix-ocsp-basic + response: new asn1js.OctetString({ valueHex: basicOCSPRaw.toBER(false) }) + }); + + const ocspResponse = new pkijs.OCSPResponse({ + responseStatus: new asn1js.Enumerated({ value: 0 }), // successful + responseBytes + }); + + const der = new Uint8Array(ocspResponse.toSchema().toBER(false)); + + const result = parseOCSPResponse(der); + expect(result.status).toBe(0); + expect(result.certStatus).toBe(1); // REVOKED + }); +}); diff --git a/packages/tests/test/unit/tsa-response.test.ts b/packages/tests/test/unit/tsa-response.test.ts index b9e3e9a..2436250 100644 --- a/packages/tests/test/unit/tsa-response.test.ts +++ b/packages/tests/test/unit/tsa-response.test.ts @@ -62,8 +62,6 @@ describe("TSA Response", () => { // Test different invalid status scenarios const invalidStatuses = [ new Uint8Array([0x30, 0x05, 0x02, 0x01, 0x03]), // status = 3 - new Uint8Array([0x30, 0x05, 0x02, 0x01, 0x04]), // status = 4 - new Uint8Array([0x30, 0x05, 0x02, 0x01, 0x05]), // status = 5 new Uint8Array([0x30, 0x05, 0x02, 0x01, 0x06]), // status = 6 ]; @@ -71,6 +69,17 @@ describe("TSA Response", () => { expect(() => parseTimestampResponse(invalidResponse)).toThrow(TimestampError); }); }); + + it("should allow status 4 (REVOCATION_WARNING) and 5 (REVOCATION_NOTIFICATION)", () => { + // Mock responses with status 4 and 5 (at least 11 bytes to pass size check) + // SEQUENCE(0x30) len 0x09 -> PKIStatusInfo SEQUENCE(0x30) len 0x03 -> status(0x02, 0x01, 0x04) + // We'll just pad it to 11 bytes. + const warningResponse = new Uint8Array([0x30, 0x09, 0x30, 0x03, 0x02, 0x01, 0x04, 0x00, 0x00, 0x00, 0x00]); + const notificationResponse = new Uint8Array([0x30, 0x09, 0x30, 0x03, 0x02, 0x01, 0x05, 0x00, 0x00, 0x00, 0x00]); + + expect(() => parseTimestampResponse(warningResponse)).toThrow("no token found"); + expect(() => parseTimestampResponse(notificationResponse)).toThrow("no token found"); + }); }); describe("validateTimestampResponse", () => {