Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
KNOWN_TSA_URLS,
TimestampError,
type HashAlgorithm,
setLogger,
} from "pdf-rfc3161";
import * as pkijs from "pkijs";

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,9 @@ export async function timestampPdf(options: TimestampOptions): Promise<Timestamp

if (
tsResponse.status !== TSAStatus.GRANTED &&
tsResponse.status !== TSAStatus.GRANTED_WITH_MODS
tsResponse.status !== TSAStatus.GRANTED_WITH_MODS &&
tsResponse.status !== TSAStatus.REVOCATION_WARNING &&
tsResponse.status !== TSAStatus.REVOCATION_NOTIFICATION
) {
throw new TimestampError(
TimestampErrorCode.TSA_ERROR,
Expand Down
23 changes: 11 additions & 12 deletions packages/core/src/pdf/ltv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
import { fetchOCSPResponse } from "../pki/ocsp-client.js";
import { getCRLDistributionPoints } from "../pki/crl-utils.js";
import { fetchCRL } from "../pki/crl-client.js";
import { getCaIssuers } from "../pki/cert-utils.js";
import { getCaIssuers, findIssuer } from "../pki/cert-utils.js";
import { fetchCertificate } from "../pki/cert-client.js";
import { bytesToHex } from "../utils.js";
import { getLogger } from "../utils/logger.js";
Expand Down Expand Up @@ -511,17 +511,16 @@ export async function completeLTVData(
// that is *different* (or if we do, root OCSP is rare/uncommon).
for (const cert of certs) {
// Find issuer
// Simple check: issuer's subject == cert's issuer
// This is O(N^2) but N is small (chain length ~3-4)
const issuer = certs.find(
(c) =>
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;
}

Expand All @@ -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;
}
Expand All @@ -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)}`
);
}
}
Expand Down
104 changes: 104 additions & 0 deletions packages/core/src/pki/cert-utils.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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];
}
71 changes: 66 additions & 5 deletions packages/core/src/pki/ocsp-utils.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion packages/core/src/tsa/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -222,14 +223,32 @@ 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,
failInfo,
};
}

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) {
Expand Down
Loading
Loading