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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ 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.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.2.4] - 2025-12-31

### Fixed

- **OCSP issuerKeyHash calculation** - Fixed critical bug where OCSP requests used wrong hash (full SPKI instead of public key BIT STRING), causing incorrect revocation status responses
- **Timestamp signature coverage verification** - Now correctly verifies that timestamps cover the canonicalized ds:SignatureValue XML element per XAdES (ETSI EN 319 132-1) specification, fixing `coversSignature: false` issue
- **TSA name formatting** - Fixed timestamp TSA name showing as `[object Object]` instead of readable DN string like `CN=..., O=..., C=...`
- **Base64 whitespace handling** - Fixed browser `atob` errors when decoding base64 strings containing whitespace from XML
- **ECDSA signature format normalization** - Fixed signature verification failures for ECDSA signatures with leading zero padding by normalizing to IEEE P1363 format expected by Web Crypto API

## [0.2.3] - 2025-12-30

### Fixed
Expand Down Expand Up @@ -60,6 +70,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- File checksum verification (SHA-256/384/512)
- Browser and Node.js support

[0.2.4]: https://github.com/edgarsj/edockit/compare/v0.2.3...v0.2.4
[0.2.3]: https://github.com/edgarsj/edockit/compare/v0.2.2...v0.2.3
[0.2.2]: https://github.com/edgarsj/edockit/compare/v0.2.1...v0.2.2
[0.2.1]: https://github.com/edgarsj/edockit/compare/v0.2.0...v0.2.1
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "edockit",
"version": "0.2.3",
"version": "0.2.4",
"main": "dist/index.cjs.js",
"scripts": {
"test": "jest --silent",
Expand Down
14 changes: 13 additions & 1 deletion src/core/parser/signatureParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
queryByXPath,
serializeToXML,
} from "../../utils/xmlParser";
import { CANONICALIZATION_METHODS } from "../canonicalization/XMLCanonicalizer";
import { XMLCanonicalizer, CANONICALIZATION_METHODS } from "../canonicalization/XMLCanonicalizer";
import { extractSignerInfo } from "../certificate";
import { SignatureInfo } from "./types";
import { formatPEM } from "./certificateUtils";
Expand Down Expand Up @@ -152,6 +152,17 @@ export function parseSignatureElement(signatureElement: Element, xmlDoc: Documen
const signatureValueEl = querySelector(signatureElement, "ds\\:SignatureValue, SignatureValue");
const signatureValue = signatureValueEl?.textContent?.replace(/\s+/g, "") || "";

// Compute canonicalized SignatureValue element for timestamp verification
let canonicalSignatureValue: string | undefined;
if (signatureValueEl) {
try {
const canonicalizer = new XMLCanonicalizer();
canonicalSignatureValue = canonicalizer.canonicalize(signatureValueEl);
} catch {
// Canonicalization failed - leave undefined
}
}

// Get certificate(s)
let certificate = "";
let certificatePEM = "";
Expand Down Expand Up @@ -306,6 +317,7 @@ export function parseSignatureElement(signatureElement: Element, xmlDoc: Documen
references,
algorithm: signatureAlgorithm,
signatureValue,
canonicalSignatureValue,
signedInfoXml,
canonicalizationMethod,
signatureTimestamp,
Expand Down
1 change: 1 addition & 0 deletions src/core/parser/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface SignatureInfo {
references: string[]; // Filenames referenced by this signature
algorithm?: string; // Signature algorithm URI
signatureValue?: string; // Base64 signature value
canonicalSignatureValue?: string; // Canonicalized ds:SignatureValue element (for timestamp verification)
signedInfoXml?: string; // The XML string of the SignedInfo element
rawXml?: string; // The full raw XML of the signature
canonicalizationMethod?: string; // The canonicalization method used
Expand Down
14 changes: 9 additions & 5 deletions src/core/revocation/ocsp.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// src/core/revocation/ocsp.ts

import { X509Certificate, AuthorityInfoAccessExtension } from "@peculiar/x509";
import { AsnConvert } from "@peculiar/asn1-schema";
import { AsnConvert, AsnParser } from "@peculiar/asn1-schema";
import {
OCSPRequest,
OCSPResponse,
Expand All @@ -11,7 +11,7 @@ import {
OCSPResponseStatus,
BasicOCSPResponse,
} from "@peculiar/asn1-ocsp";
import { AlgorithmIdentifier } from "@peculiar/asn1-x509";
import { AlgorithmIdentifier, Certificate } from "@peculiar/asn1-x509";
import { OctetString } from "@peculiar/asn1-schema";
import { RevocationResult } from "./types";
import { fetchOCSP, fetchIssuerCertificate } from "./fetch";
Expand Down Expand Up @@ -152,11 +152,15 @@ export async function buildOCSPRequest(
issuerCert: X509Certificate,
): Promise<ArrayBuffer> {
// Get issuer name hash (SHA-1 of issuer's DN in DER)
const issuerNameDer = AsnConvert.serialize(issuerCert.subjectName.toJSON());
// Parse the raw certificate to get the proper ASN.1 structures for serialization
const issuerCertAsn = AsnParser.parse(issuerCert.rawData, Certificate);
const issuerNameDer = AsnConvert.serialize(issuerCertAsn.tbsCertificate.subject);
const issuerNameHash = await computeSHA1(issuerNameDer);

// Get issuer key hash (SHA-1 of issuer's public key)
const issuerKeyHash = await computeSHA1(issuerCert.publicKey.rawData);
// Get issuer key hash (SHA-1 of issuer's public key BIT STRING value, not the full SPKI)
const issuerKeyHash = await computeSHA1(
issuerCertAsn.tbsCertificate.subjectPublicKeyInfo.subjectPublicKey,
);

// Get certificate serial number
const serialNumber = hexToArrayBuffer(cert.serialNumber);
Expand Down
4 changes: 2 additions & 2 deletions src/core/timestamp/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ export interface TimestampVerificationResult {
* Options for timestamp verification
*/
export interface TimestampVerificationOptions {
/** The signature value that the timestamp should cover (base64) */
signatureValue?: string;
/** The canonicalized ds:SignatureValue XML element (per XAdES spec) */
canonicalSignatureValue?: string;
/** Verify the TSA certificate chain */
verifyTsaCertificate?: boolean;
/** Check TSA certificate revocation */
Expand Down
84 changes: 51 additions & 33 deletions src/core/timestamp/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { X509Certificate } from "@peculiar/x509";
import { AsnConvert } from "@peculiar/asn1-schema";
import { ContentInfo, SignedData } from "@peculiar/asn1-cms";
import { TSTInfo } from "@peculiar/asn1-tsp";
import { Name } from "@peculiar/asn1-x509";
import { TimestampInfo, TimestampVerificationResult, TimestampVerificationOptions } from "./types";
import { checkCertificateRevocation } from "../revocation/check";
import { RevocationResult } from "../revocation/types";
Expand Down Expand Up @@ -37,6 +38,40 @@ function getHashAlgorithmName(oid: string): string {
return hashAlgorithms[oid] || oid;
}

/**
* Common OID to attribute name mappings for X.500 distinguished names
*/
const oidToAttributeName: Record<string, string> = {
"2.5.4.3": "CN",
"2.5.4.6": "C",
"2.5.4.7": "L",
"2.5.4.8": "ST",
"2.5.4.10": "O",
"2.5.4.11": "OU",
"2.5.4.5": "serialNumber",
"1.2.840.113549.1.9.1": "emailAddress",
};

/**
* Format an X.500 Name (directoryName) to a readable string
* @param name The Name object to format
* @returns Formatted string like "CN=Example, O=Company, C=US"
*/
function formatDirectoryName(name: Name): string {
const parts: string[] = [];
for (const rdn of name) {
for (const attr of rdn) {
const attrName = oidToAttributeName[attr.type] || attr.type;
// attr.value can be various ASN.1 string types
const value = attr.value?.toString() || "";
if (value) {
parts.push(`${attrName}=${value}`);
}
}
}
return parts.join(", ");
}

/**
* Parse RFC 3161 TimeStampToken from base64
* @param timestampBase64 Base64-encoded timestamp token
Expand Down Expand Up @@ -99,7 +134,7 @@ export function parseTimestamp(timestampBase64: string): TimestampInfo | null {
let tsaName: string | undefined;
if (tstInfo.tsa) {
if (tstInfo.tsa.directoryName) {
tsaName = tstInfo.tsa.directoryName.toString();
tsaName = formatDirectoryName(tstInfo.tsa.directoryName);
} else if (tstInfo.tsa.uniformResourceIdentifier) {
tsaName = tstInfo.tsa.uniformResourceIdentifier;
}
Expand Down Expand Up @@ -163,44 +198,30 @@ async function computeHash(data: ArrayBuffer, algorithm: string): Promise<ArrayB

/**
* Verify that timestamp covers the signature value
* XAdES timestamps can cover either:
* 1. The decoded signature value bytes (standard per ETSI EN 319 132-1)
* 2. The base64-encoded string (some implementations)
*
* Per XAdES (ETSI EN 319 132-1), the SignatureTimeStamp covers the canonicalized
* ds:SignatureValue XML element, not just its base64 content.
*
* @param timestampInfo Parsed timestamp info
* @param signatureValueBase64 Base64-encoded signature value
* @param canonicalSignatureValue Canonicalized ds:SignatureValue XML element
* @returns True if the timestamp covers the signature
*/
export async function verifyTimestampCoversSignature(
timestampInfo: TimestampInfo,
signatureValueBase64: string,
canonicalSignatureValue: string,
): Promise<boolean> {
try {
const messageImprintLower = timestampInfo.messageImprint.toLowerCase();

// Try 1: Hash of decoded signature value bytes (standard approach)
const signatureValue = base64ToArrayBuffer(signatureValueBase64);
const computedHash = await computeHash(signatureValue, timestampInfo.hashAlgorithm);
const computedHashHex = arrayBufferToHex(computedHash);

if (computedHashHex.toLowerCase() === messageImprintLower) {
return true;
}

// Try 2: Hash of base64 string (some implementations)
const encoder = new TextEncoder();
const base64Bytes = encoder.encode(signatureValueBase64);
const base64Hash = await computeHash(
base64Bytes.buffer as ArrayBuffer,

const canonicalBytes = encoder.encode(canonicalSignatureValue);
const canonicalHash = await computeHash(
canonicalBytes.buffer as ArrayBuffer,
timestampInfo.hashAlgorithm,
);
const base64HashHex = arrayBufferToHex(base64Hash);

if (base64HashHex.toLowerCase() === messageImprintLower) {
return true;
}
const canonicalHashHex = arrayBufferToHex(canonicalHash);

return false;
return canonicalHashHex.toLowerCase() === messageImprintLower;
} catch (error) {
console.error(
"Failed to verify timestamp coverage:",
Expand Down Expand Up @@ -230,15 +251,12 @@ export async function verifyTimestamp(
}

// Verify timestamp covers the signature if provided
// Note: coversSignature failure is informational - the timestamp is still valid
// and can be used for genTime. The signature value hashing varies by implementation.
let coversSignature: boolean | undefined;
let coversSignatureReason: string | undefined;
if (options.signatureValue) {
coversSignature = await verifyTimestampCoversSignature(info, options.signatureValue);
if (options.canonicalSignatureValue) {
coversSignature = await verifyTimestampCoversSignature(info, options.canonicalSignatureValue);
if (!coversSignature) {
coversSignatureReason =
"Could not verify timestamp covers signature (implementation-specific hashing)";
coversSignatureReason = "Could not verify timestamp covers signature (hash mismatch)";
}
}

Expand Down Expand Up @@ -275,7 +293,7 @@ export async function verifyTimestamp(
};
}
// Note: 'unknown' status is a soft fail - timestamp remains valid
// but user can check tsaRevocation.status to see if it couldn't be verified
// but user can check tsaRevocation.status to see the actual status
} catch (error) {
// Revocation check failed - soft fail, add to result but don't invalidate
tsaRevocation = {
Expand Down
Loading