edockit 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.4] - 2025-12-31
9
+
10
+ ### Fixed
11
+
12
+ - **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
13
+ - **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
14
+ - **TSA name formatting** - Fixed timestamp TSA name showing as `[object Object]` instead of readable DN string like `CN=..., O=..., C=...`
15
+ - **Base64 whitespace handling** - Fixed browser `atob` errors when decoding base64 strings containing whitespace from XML
16
+ - **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
17
+
18
+ ## [0.2.3] - 2025-12-30
19
+
20
+ ### Fixed
21
+
22
+ - **Long-Term Validation (LTV) for revoked certificates** - Signatures made before certificate revocation are now correctly validated as valid when a trusted timestamp proves the signing time
23
+
8
24
  ## [0.2.2] - 2025-12-30
9
25
 
10
26
  ### Fixed
@@ -54,6 +70,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
54
70
  - File checksum verification (SHA-256/384/512)
55
71
  - Browser and Node.js support
56
72
 
73
+ [0.2.4]: https://github.com/edgarsj/edockit/compare/v0.2.3...v0.2.4
74
+ [0.2.3]: https://github.com/edgarsj/edockit/compare/v0.2.2...v0.2.3
57
75
  [0.2.2]: https://github.com/edgarsj/edockit/compare/v0.2.1...v0.2.2
58
76
  [0.2.1]: https://github.com/edgarsj/edockit/compare/v0.2.0...v0.2.1
59
77
  [0.2.0]: https://github.com/edgarsj/edockit/compare/v0.1.2...v0.2.0
@@ -34,6 +34,7 @@ export interface SignatureInfo {
34
34
  references: string[];
35
35
  algorithm?: string;
36
36
  signatureValue?: string;
37
+ canonicalSignatureValue?: string;
37
38
  signedInfoXml?: string;
38
39
  rawXml?: string;
39
40
  canonicalizationMethod?: string;
@@ -39,8 +39,8 @@ export interface TimestampVerificationResult {
39
39
  * Options for timestamp verification
40
40
  */
41
41
  export interface TimestampVerificationOptions {
42
- /** The signature value that the timestamp should cover (base64) */
43
- signatureValue?: string;
42
+ /** The canonicalized ds:SignatureValue XML element (per XAdES spec) */
43
+ canonicalSignatureValue?: string;
44
44
  /** Verify the TSA certificate chain */
45
45
  verifyTsaCertificate?: boolean;
46
46
  /** Check TSA certificate revocation */
@@ -7,15 +7,15 @@ import { TimestampInfo, TimestampVerificationResult, TimestampVerificationOption
7
7
  export declare function parseTimestamp(timestampBase64: string): TimestampInfo | null;
8
8
  /**
9
9
  * Verify that timestamp covers the signature value
10
- * XAdES timestamps can cover either:
11
- * 1. The decoded signature value bytes (standard per ETSI EN 319 132-1)
12
- * 2. The base64-encoded string (some implementations)
10
+ *
11
+ * Per XAdES (ETSI EN 319 132-1), the SignatureTimeStamp covers the canonicalized
12
+ * ds:SignatureValue XML element, not just its base64 content.
13
13
  *
14
14
  * @param timestampInfo Parsed timestamp info
15
- * @param signatureValueBase64 Base64-encoded signature value
15
+ * @param canonicalSignatureValue Canonicalized ds:SignatureValue XML element
16
16
  * @returns True if the timestamp covers the signature
17
17
  */
18
- export declare function verifyTimestampCoversSignature(timestampInfo: TimestampInfo, signatureValueBase64: string): Promise<boolean>;
18
+ export declare function verifyTimestampCoversSignature(timestampInfo: TimestampInfo, canonicalSignatureValue: string): Promise<boolean>;
19
19
  /**
20
20
  * Verify an RFC 3161 timestamp token
21
21
  * @param timestampBase64 Base64-encoded timestamp token
package/dist/index.cjs.js CHANGED
@@ -1212,6 +1212,17 @@ function parseSignatureElement(signatureElement, xmlDoc) {
1212
1212
  // Get signature value
1213
1213
  const signatureValueEl = querySelector(signatureElement, "ds\\:SignatureValue, SignatureValue");
1214
1214
  const signatureValue = signatureValueEl?.textContent?.replace(/\s+/g, "") || "";
1215
+ // Compute canonicalized SignatureValue element for timestamp verification
1216
+ let canonicalSignatureValue;
1217
+ if (signatureValueEl) {
1218
+ try {
1219
+ const canonicalizer = new XMLCanonicalizer();
1220
+ canonicalSignatureValue = canonicalizer.canonicalize(signatureValueEl);
1221
+ }
1222
+ catch {
1223
+ // Canonicalization failed - leave undefined
1224
+ }
1225
+ }
1215
1226
  // Get certificate(s)
1216
1227
  let certificate = "";
1217
1228
  let certificatePEM = "";
@@ -1343,6 +1354,7 @@ function parseSignatureElement(signatureElement, xmlDoc) {
1343
1354
  references,
1344
1355
  algorithm: signatureAlgorithm,
1345
1356
  signatureValue,
1357
+ canonicalSignatureValue,
1346
1358
  signedInfoXml,
1347
1359
  canonicalizationMethod,
1348
1360
  signatureTimestamp,
@@ -8068,11 +8080,14 @@ function arrayBufferToBase64(buffer) {
8068
8080
  }
8069
8081
  /**
8070
8082
  * Convert base64 string to ArrayBuffer
8083
+ * Handles base64 strings with whitespace (common in XML)
8071
8084
  */
8072
8085
  function base64ToArrayBuffer(base64) {
8086
+ // Strip whitespace (newlines, spaces, tabs) that may be present in XML
8087
+ const cleanBase64 = base64.replace(/\s/g, "");
8073
8088
  if (typeof atob === "function") {
8074
8089
  // Browser
8075
- const binary = atob(base64);
8090
+ const binary = atob(cleanBase64);
8076
8091
  const bytes = new Uint8Array(binary.length);
8077
8092
  for (let i = 0; i < binary.length; i++) {
8078
8093
  bytes[i] = binary.charCodeAt(i);
@@ -8080,7 +8095,7 @@ function base64ToArrayBuffer(base64) {
8080
8095
  return bytes.buffer;
8081
8096
  }
8082
8097
  // Node.js
8083
- const buffer = Buffer.from(base64, "base64");
8098
+ const buffer = Buffer.from(cleanBase64, "base64");
8084
8099
  return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
8085
8100
  }
8086
8101
  /**
@@ -8233,10 +8248,12 @@ async function fetchIssuerFromAIA(cert, timeout = 5000, proxyUrl) {
8233
8248
  */
8234
8249
  async function buildOCSPRequest(cert, issuerCert) {
8235
8250
  // Get issuer name hash (SHA-1 of issuer's DN in DER)
8236
- const issuerNameDer = AsnConvert.serialize(issuerCert.subjectName.toJSON());
8251
+ // Parse the raw certificate to get the proper ASN.1 structures for serialization
8252
+ const issuerCertAsn = AsnParser.parse(issuerCert.rawData, Certificate);
8253
+ const issuerNameDer = AsnConvert.serialize(issuerCertAsn.tbsCertificate.subject);
8237
8254
  const issuerNameHash = await computeSHA1(issuerNameDer);
8238
- // Get issuer key hash (SHA-1 of issuer's public key)
8239
- const issuerKeyHash = await computeSHA1(issuerCert.publicKey.rawData);
8255
+ // Get issuer key hash (SHA-1 of issuer's public key BIT STRING value, not the full SPKI)
8256
+ const issuerKeyHash = await computeSHA1(issuerCertAsn.tbsCertificate.subjectPublicKeyInfo.subjectPublicKey);
8240
8257
  // Get certificate serial number
8241
8258
  const serialNumber = hexToArrayBuffer(cert.serialNumber);
8242
8259
  // Build CertID
@@ -9948,6 +9965,38 @@ function getHashAlgorithmName(oid) {
9948
9965
  };
9949
9966
  return hashAlgorithms[oid] || oid;
9950
9967
  }
9968
+ /**
9969
+ * Common OID to attribute name mappings for X.500 distinguished names
9970
+ */
9971
+ const oidToAttributeName = {
9972
+ "2.5.4.3": "CN",
9973
+ "2.5.4.6": "C",
9974
+ "2.5.4.7": "L",
9975
+ "2.5.4.8": "ST",
9976
+ "2.5.4.10": "O",
9977
+ "2.5.4.11": "OU",
9978
+ "2.5.4.5": "serialNumber",
9979
+ "1.2.840.113549.1.9.1": "emailAddress",
9980
+ };
9981
+ /**
9982
+ * Format an X.500 Name (directoryName) to a readable string
9983
+ * @param name The Name object to format
9984
+ * @returns Formatted string like "CN=Example, O=Company, C=US"
9985
+ */
9986
+ function formatDirectoryName(name) {
9987
+ const parts = [];
9988
+ for (const rdn of name) {
9989
+ for (const attr of rdn) {
9990
+ const attrName = oidToAttributeName[attr.type] || attr.type;
9991
+ // attr.value can be various ASN.1 string types
9992
+ const value = attr.value?.toString() || "";
9993
+ if (value) {
9994
+ parts.push(`${attrName}=${value}`);
9995
+ }
9996
+ }
9997
+ }
9998
+ return parts.join(", ");
9999
+ }
9951
10000
  /**
9952
10001
  * Parse RFC 3161 TimeStampToken from base64
9953
10002
  * @param timestampBase64 Base64-encoded timestamp token
@@ -10003,7 +10052,7 @@ function parseTimestamp(timestampBase64) {
10003
10052
  let tsaName;
10004
10053
  if (tstInfo.tsa) {
10005
10054
  if (tstInfo.tsa.directoryName) {
10006
- tsaName = tstInfo.tsa.directoryName.toString();
10055
+ tsaName = formatDirectoryName(tstInfo.tsa.directoryName);
10007
10056
  }
10008
10057
  else if (tstInfo.tsa.uniformResourceIdentifier) {
10009
10058
  tsaName = tstInfo.tsa.uniformResourceIdentifier;
@@ -10059,33 +10108,22 @@ async function computeHash(data, algorithm) {
10059
10108
  }
10060
10109
  /**
10061
10110
  * Verify that timestamp covers the signature value
10062
- * XAdES timestamps can cover either:
10063
- * 1. The decoded signature value bytes (standard per ETSI EN 319 132-1)
10064
- * 2. The base64-encoded string (some implementations)
10111
+ *
10112
+ * Per XAdES (ETSI EN 319 132-1), the SignatureTimeStamp covers the canonicalized
10113
+ * ds:SignatureValue XML element, not just its base64 content.
10065
10114
  *
10066
10115
  * @param timestampInfo Parsed timestamp info
10067
- * @param signatureValueBase64 Base64-encoded signature value
10116
+ * @param canonicalSignatureValue Canonicalized ds:SignatureValue XML element
10068
10117
  * @returns True if the timestamp covers the signature
10069
10118
  */
10070
- async function verifyTimestampCoversSignature(timestampInfo, signatureValueBase64) {
10119
+ async function verifyTimestampCoversSignature(timestampInfo, canonicalSignatureValue) {
10071
10120
  try {
10072
10121
  const messageImprintLower = timestampInfo.messageImprint.toLowerCase();
10073
- // Try 1: Hash of decoded signature value bytes (standard approach)
10074
- const signatureValue = base64ToArrayBuffer(signatureValueBase64);
10075
- const computedHash = await computeHash(signatureValue, timestampInfo.hashAlgorithm);
10076
- const computedHashHex = arrayBufferToHex(computedHash);
10077
- if (computedHashHex.toLowerCase() === messageImprintLower) {
10078
- return true;
10079
- }
10080
- // Try 2: Hash of base64 string (some implementations)
10081
10122
  const encoder = new TextEncoder();
10082
- const base64Bytes = encoder.encode(signatureValueBase64);
10083
- const base64Hash = await computeHash(base64Bytes.buffer, timestampInfo.hashAlgorithm);
10084
- const base64HashHex = arrayBufferToHex(base64Hash);
10085
- if (base64HashHex.toLowerCase() === messageImprintLower) {
10086
- return true;
10087
- }
10088
- return false;
10123
+ const canonicalBytes = encoder.encode(canonicalSignatureValue);
10124
+ const canonicalHash = await computeHash(canonicalBytes.buffer, timestampInfo.hashAlgorithm);
10125
+ const canonicalHashHex = arrayBufferToHex(canonicalHash);
10126
+ return canonicalHashHex.toLowerCase() === messageImprintLower;
10089
10127
  }
10090
10128
  catch (error) {
10091
10129
  console.error("Failed to verify timestamp coverage:", error instanceof Error ? error.message : String(error));
@@ -10108,15 +10146,12 @@ async function verifyTimestamp(timestampBase64, options = {}) {
10108
10146
  };
10109
10147
  }
10110
10148
  // Verify timestamp covers the signature if provided
10111
- // Note: coversSignature failure is informational - the timestamp is still valid
10112
- // and can be used for genTime. The signature value hashing varies by implementation.
10113
10149
  let coversSignature;
10114
10150
  let coversSignatureReason;
10115
- if (options.signatureValue) {
10116
- coversSignature = await verifyTimestampCoversSignature(info, options.signatureValue);
10151
+ if (options.canonicalSignatureValue) {
10152
+ coversSignature = await verifyTimestampCoversSignature(info, options.canonicalSignatureValue);
10117
10153
  if (!coversSignature) {
10118
- coversSignatureReason =
10119
- "Could not verify timestamp covers signature (implementation-specific hashing)";
10154
+ coversSignatureReason = "Could not verify timestamp covers signature (hash mismatch)";
10120
10155
  }
10121
10156
  }
10122
10157
  // Verify TSA certificate if requested
@@ -10148,7 +10183,7 @@ async function verifyTimestamp(timestampBase64, options = {}) {
10148
10183
  };
10149
10184
  }
10150
10185
  // Note: 'unknown' status is a soft fail - timestamp remains valid
10151
- // but user can check tsaRevocation.status to see if it couldn't be verified
10186
+ // but user can check tsaRevocation.status to see the actual status
10152
10187
  }
10153
10188
  catch (error) {
10154
10189
  // Revocation check failed - soft fail, add to result but don't invalidate
@@ -10403,6 +10438,115 @@ async function verifyCertificate(certificatePEM, verifyTime = new Date()) {
10403
10438
  };
10404
10439
  }
10405
10440
  }
10441
+ /**
10442
+ * Get the expected component size for an ECDSA curve
10443
+ */
10444
+ function getEcdsaComponentSize(namedCurve) {
10445
+ switch (namedCurve) {
10446
+ case "P-256":
10447
+ return 32;
10448
+ case "P-384":
10449
+ return 48;
10450
+ case "P-521":
10451
+ return 66;
10452
+ default:
10453
+ return 32; // Default to P-256
10454
+ }
10455
+ }
10456
+ /**
10457
+ * Normalize ECDSA signature to IEEE P1363 format (raw R||S) expected by Web Crypto API
10458
+ * Handles both raw format with potential padding and DER-encoded signatures
10459
+ */
10460
+ function normalizeEcdsaSignature(signatureBytes, namedCurve) {
10461
+ const componentSize = getEcdsaComponentSize(namedCurve);
10462
+ const expectedLength = componentSize * 2;
10463
+ // If already correct length, return as-is
10464
+ if (signatureBytes.length === expectedLength) {
10465
+ return signatureBytes;
10466
+ }
10467
+ // Check if it's DER-encoded (starts with SEQUENCE tag 0x30)
10468
+ if (signatureBytes[0] === 0x30) {
10469
+ return derToRawEcdsa(signatureBytes, componentSize);
10470
+ }
10471
+ // Handle raw R||S with potential leading zeros
10472
+ // Some implementations pad R and S with leading zeros
10473
+ if (signatureBytes.length > expectedLength) {
10474
+ const halfLength = signatureBytes.length / 2;
10475
+ if (Number.isInteger(halfLength)) {
10476
+ const r = signatureBytes.slice(0, halfLength);
10477
+ const s = signatureBytes.slice(halfLength);
10478
+ // Normalize R and S to exact component size
10479
+ const normalizedR = normalizeComponent(r, componentSize);
10480
+ const normalizedS = normalizeComponent(s, componentSize);
10481
+ const result = new Uint8Array(expectedLength);
10482
+ result.set(normalizedR, 0);
10483
+ result.set(normalizedS, componentSize);
10484
+ return result;
10485
+ }
10486
+ }
10487
+ // Return as-is if we can't normalize
10488
+ return signatureBytes;
10489
+ }
10490
+ /**
10491
+ * Normalize a single ECDSA component (R or S) to exact size
10492
+ * Strips leading zeros or pads with leading zeros as needed
10493
+ */
10494
+ function normalizeComponent(component, size) {
10495
+ // Strip leading zeros
10496
+ let start = 0;
10497
+ while (start < component.length - 1 && component[start] === 0) {
10498
+ start++;
10499
+ }
10500
+ const stripped = component.slice(start);
10501
+ if (stripped.length === size) {
10502
+ return stripped;
10503
+ }
10504
+ else if (stripped.length < size) {
10505
+ // Pad with leading zeros
10506
+ const padded = new Uint8Array(size);
10507
+ padded.set(stripped, size - stripped.length);
10508
+ return padded;
10509
+ }
10510
+ else {
10511
+ // Component too large - take the last 'size' bytes
10512
+ return stripped.slice(stripped.length - size);
10513
+ }
10514
+ }
10515
+ /**
10516
+ * Convert DER-encoded ECDSA signature to raw IEEE P1363 format
10517
+ */
10518
+ function derToRawEcdsa(derSignature, componentSize) {
10519
+ // DER structure: SEQUENCE { INTEGER r, INTEGER s }
10520
+ // 0x30 len 0x02 rLen r... 0x02 sLen s...
10521
+ let offset = 0;
10522
+ // Skip SEQUENCE tag
10523
+ if (derSignature[offset++] !== 0x30) {
10524
+ throw new Error("Invalid DER signature: missing SEQUENCE tag");
10525
+ }
10526
+ // Skip sequence length (may be 1 or 2 bytes)
10527
+ const seqLen = derSignature[offset++];
10528
+ if (seqLen & 0x80) {
10529
+ offset += seqLen & 0x7f; // Skip length bytes
10530
+ }
10531
+ // Parse R
10532
+ if (derSignature[offset++] !== 0x02) {
10533
+ throw new Error("Invalid DER signature: missing INTEGER tag for R");
10534
+ }
10535
+ const rLen = derSignature[offset++];
10536
+ const r = derSignature.slice(offset, offset + rLen);
10537
+ offset += rLen;
10538
+ // Parse S
10539
+ if (derSignature[offset++] !== 0x02) {
10540
+ throw new Error("Invalid DER signature: missing INTEGER tag for S");
10541
+ }
10542
+ const sLen = derSignature[offset++];
10543
+ const s = derSignature.slice(offset, offset + sLen);
10544
+ // Normalize and concatenate
10545
+ const result = new Uint8Array(componentSize * 2);
10546
+ result.set(normalizeComponent(r, componentSize), 0);
10547
+ result.set(normalizeComponent(s, componentSize), componentSize);
10548
+ return result;
10549
+ }
10406
10550
  /**
10407
10551
  * Safely get the crypto.subtle implementation in either browser or Node.js
10408
10552
  * @returns The SubtleCrypto interface
@@ -10454,6 +10598,10 @@ async function verifySignedInfo(signatureXml, signatureValue, publicKeyData, alg
10454
10598
  reason: `Failed to decode signature value: ${error instanceof Error ? error.message : String(error)}`,
10455
10599
  };
10456
10600
  }
10601
+ // For ECDSA, normalize signature to IEEE P1363 format (raw R||S) expected by Web Crypto
10602
+ if (algorithm.name === "ECDSA") {
10603
+ signatureBytes = normalizeEcdsaSignature(signatureBytes, algorithm.namedCurve);
10604
+ }
10457
10605
  // Import the public key
10458
10606
  let publicKey;
10459
10607
  try {
@@ -10542,7 +10690,7 @@ async function verifySignature(signatureInfo, files, options = {}) {
10542
10690
  let trustedSigningTime = options.verifyTime || signatureInfo.signingTime;
10543
10691
  if (signatureInfo.signatureTimestamp && options.verifyTimestamps !== false) {
10544
10692
  timestampResult = await verifyTimestamp(signatureInfo.signatureTimestamp, {
10545
- signatureValue: signatureInfo.signatureValue,
10693
+ canonicalSignatureValue: signatureInfo.canonicalSignatureValue,
10546
10694
  verifyTsaCertificate: true,
10547
10695
  revocationOptions: options.revocationOptions,
10548
10696
  });
@@ -10569,14 +10717,28 @@ async function verifySignature(signatureInfo, files, options = {}) {
10569
10717
  ...options.revocationOptions,
10570
10718
  });
10571
10719
  certResult.revocation = revocationResult;
10572
- // If certificate is revoked, mark certificate as invalid
10720
+ // If certificate is revoked, check if signature was made before revocation (LTV)
10573
10721
  if (revocationResult.status === "revoked") {
10574
- certResult.isValid = false;
10575
- certResult.reason = revocationResult.reason || "Certificate has been revoked";
10576
- errors.push(`Certificate revoked: ${revocationResult.reason || "No reason provided"}`);
10722
+ const revokedAt = revocationResult.revokedAt;
10723
+ const hasValidRevocationTime = revokedAt && revokedAt.getTime() > 0;
10724
+ // Long-Term Validation: if we have a trusted timestamp proving the signature
10725
+ // was made before revocation, the signature is still valid
10726
+ if (hasValidRevocationTime && trustedSigningTime < revokedAt) {
10727
+ // Signature was made before revocation - still valid (LTV)
10728
+ certResult.revocation.isValid = true;
10729
+ certResult.revocation.reason = `Certificate was revoked on ${revokedAt.toISOString()}, but signature was made on ${trustedSigningTime.toISOString()} (before revocation)`;
10730
+ }
10731
+ else {
10732
+ // Signature was made after revocation or no valid revocation date available
10733
+ certResult.isValid = false;
10734
+ const revokedAtStr = hasValidRevocationTime ? ` on ${revokedAt.toISOString()}` : "";
10735
+ certResult.reason =
10736
+ revocationResult.reason || `Certificate has been revoked${revokedAtStr}`;
10737
+ errors.push(revocationResult.reason || `Certificate revoked${revokedAtStr}`);
10738
+ }
10577
10739
  }
10578
10740
  // Note: 'unknown' status is a soft fail - certificate remains valid
10579
- // but user can check revocation.status to see if it couldn't be verified
10741
+ // but user can check revocation.status to see the actual status
10580
10742
  }
10581
10743
  catch (error) {
10582
10744
  // Revocation check failed - soft fail, add to result but don't invalidate