UNPKG

edockit

Version:

A JavaScript library for listing, parsing, and verifying the contents and signatures of electronic documents (eDoc) and Associated Signature Containers (ASiC-E), supporting EU eIDAS standards for digital signatures and electronic seals.

1,178 lines (1,171 loc) 414 kB
/*! * MIT License * Copyright (c) 2025 Edgars Jēkabsons, ZenomyTech SIA */ 'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var fflate = require('fflate'); var x509 = require('@peculiar/x509'); /** * Recursive DOM traversal to find elements with a given tag name * (Fallback method when XPath is not available or fails) * * @param parent The parent element to search within * @param selector CSS-like selector with namespace support (e.g., "ds:SignedInfo, SignedInfo") * @returns Array of matching elements */ function findElementsByTagNameRecursive(parent, selector) { const results = []; const selectors = selector.split(",").map((s) => s.trim()); // Parse each selector part to extract namespace and local name const parsedSelectors = []; for (const sel of selectors) { const parts = sel.split(/\\:|:/).filter(Boolean); if (parts.length === 1) { parsedSelectors.push({ name: parts[0] }); } else if (parts.length === 2) { parsedSelectors.push({ ns: parts[0], name: parts[1] }); } } // Recursive search function - keep the original node references function searchNode(node) { if (!node) return; if (node.nodeType === 1) { // Element node - make sure we're working with an actual DOM Element const element = node; const nodeName = element.nodeName; const localName = element.localName; // Check if this element matches any of our selectors for (const sel of parsedSelectors) { // Match by full nodeName (which might include namespace prefix) if (sel.ns && nodeName === `${sel.ns}:${sel.name}`) { results.push(element); // Store the actual DOM element reference break; } // Match by local name only if (localName === sel.name || nodeName === sel.name) { results.push(element); // Store the actual DOM element reference break; } // Match by checking if nodeName ends with the local name if (nodeName.endsWith(`:${sel.name}`)) { results.push(element); // Store the actual DOM element reference break; } } } // Search all child nodes if (node.childNodes) { for (let i = 0; i < node.childNodes.length; i++) { searchNode(node.childNodes[i]); } } } searchNode(parent); return results; } // Known XML namespaces used in XML Signatures and related standards const NAMESPACES = { ds: "http://www.w3.org/2000/09/xmldsig#", dsig11: "http://www.w3.org/2009/xmldsig11#", dsig2: "http://www.w3.org/2010/xmldsig2#", ec: "http://www.w3.org/2001/10/xml-exc-c14n#", dsig_more: "http://www.w3.org/2001/04/xmldsig-more#", xenc: "http://www.w3.org/2001/04/xmlenc#", xenc11: "http://www.w3.org/2009/xmlenc11#", xades: "http://uri.etsi.org/01903/v1.3.2#", xades141: "http://uri.etsi.org/01903/v1.4.1#", asic: "http://uri.etsi.org/02918/v1.2.1#", }; /** * Create an XML parser that works in both browser and Node environments */ function createXMLParser() { // Check if we're in a browser environment with native DOM support if (typeof window !== "undefined" && window.DOMParser) { return new window.DOMParser(); } // We're in Node.js, so use xmldom try { // Import dynamically to avoid bundling issues const { DOMParser } = require("@xmldom/xmldom"); return new DOMParser(); } catch (e) { throw new Error("XML DOM parser not available. In Node.js environments, please install @xmldom/xmldom package."); } } /** * Uses XPath to find a single element in an XML document * * @param parent The parent element or document to search within * @param xpathExpression The XPath expression to evaluate * @param namespaces Optional namespace mapping (defaults to common XML signature namespaces) * @returns The found element or null */ function queryByXPath(parent, xpathExpression, namespaces = NAMESPACES) { try { // Browser environment with native XPath if (typeof document !== "undefined" && typeof document.evaluate === "function") { // Use the document that owns the parent node, not the global document const ownerDoc = "ownerDocument" in parent ? parent.ownerDocument : parent; if (!ownerDoc || typeof ownerDoc.evaluate !== "function") { // XMLDocuments from DOMParser don't have evaluate - silently return null // (caller should use DOM traversal fallback) return null; } const nsResolver = createNsResolverForBrowser(namespaces); const result = ownerDoc.evaluate(xpathExpression, parent, nsResolver, XPathResult.FIRST_ORDERED_NODE_TYPE, null); return result.singleNodeValue; } // Node.js environment with xpath module else { const xpath = require("xpath"); const nsResolver = createNsResolverForNode(namespaces); // Use a try-catch here to handle specific XPath issues try { const nodes = xpath.select(xpathExpression, parent, nsResolver); return nodes.length > 0 ? nodes[0] : null; } catch (err) { // If we get a namespace error, try a simpler XPath with just local-name() if (typeof err === "object" && err !== null && "message" in err && typeof err.message === "string" && err.message.includes("Cannot resolve QName")) { // Extract the element name we're looking for from the XPath const match = xpathExpression.match(/local-name\(\)='([^']+)'/); if (match && match[1]) { const elementName = match[1]; const simplifiedXPath = `.//*[local-name()='${elementName}']`; const nodes = xpath.select(simplifiedXPath, parent); return nodes.length > 0 ? nodes[0] : null; } } throw err; // Re-throw if we couldn't handle it } } } catch (e) { console.error(`XPath evaluation failed for "${xpathExpression}":`, e); return null; } } /** * Uses XPath to find all matching elements in an XML document * * @param parent The parent element or document to search within * @param xpathExpression The XPath expression to evaluate * @param namespaces Optional namespace mapping (defaults to common XML signature namespaces) * @returns Array of matching elements */ function queryAllByXPath(parent, xpathExpression, namespaces = NAMESPACES) { try { // Browser environment with native XPath if (typeof document !== "undefined" && typeof document.evaluate === "function") { // Use the document that owns the parent node, not the global document const ownerDoc = "ownerDocument" in parent ? parent.ownerDocument : parent; if (!ownerDoc || typeof ownerDoc.evaluate !== "function") { // XMLDocuments from DOMParser don't have evaluate - silently return empty // (caller should use DOM traversal fallback) return []; } const nsResolver = createNsResolverForBrowser(namespaces); const result = ownerDoc.evaluate(xpathExpression, parent, nsResolver, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); const elements = []; for (let i = 0; i < result.snapshotLength; i++) { elements.push(result.snapshotItem(i)); } return elements; } // Node.js environment with xpath module else { const xpath = require("xpath"); const nsResolver = createNsResolverForNode(namespaces); // Use a try-catch here to handle specific XPath issues try { const nodes = xpath.select(xpathExpression, parent, nsResolver); return nodes; } catch (err) { // If we get a namespace error, try a simpler XPath with just local-name() if (typeof err === "object" && err !== null && "message" in err && typeof err.message === "string" && err.message.includes("Cannot resolve QName")) { // Extract the element name we're looking for from the XPath const match = xpathExpression.match(/local-name\(\)='([^']+)'/); if (match && match[1]) { const elementName = match[1]; const simplifiedXPath = `.//*[local-name()='${elementName}']`; const nodes = xpath.select(simplifiedXPath, parent); return nodes; } } throw err; // Re-throw if we couldn't handle it } } } catch (e) { console.error(`XPath evaluation failed for "${xpathExpression}":`, e); return []; } } /** * Helper function to create a namespace resolver for browser environments */ function createNsResolverForBrowser(namespaces) { return function (prefix) { if (prefix === null) return null; return namespaces[prefix] || null; }; } /** * Helper function to create a namespace resolver for Node.js environments */ function createNsResolverForNode(namespaces) { return namespaces; } /** * Converts a CSS-like selector (with namespace support) to an XPath expression * * @param selector CSS-like selector (e.g., "ds:SignedInfo, SignedInfo") * @returns Equivalent XPath expression */ function selectorToXPath(selector) { // Split by comma to handle alternative selectors const parts = selector.split(",").map((s) => s.trim()); const xpathParts = []; for (const part of parts) { // Handle namespaced selectors (both prefix:name and prefix\\:name formats) const segments = part.split(/\\:|:/).filter(Boolean); if (segments.length === 1) { // Simple element name without namespace // Match any element with the right local name xpathParts.push(`.//*[local-name()='${segments[0]}']`); } else if (segments.length === 2) { // Element with namespace prefix - only use local-name() or specific namespace prefix // that we know is registered, avoiding the generic 'ns:' prefix xpathParts.push(`.//${segments[0]}:${segments[1]} | .//*[local-name()='${segments[1]}']`); } } // Join with | operator (XPath's OR) return xpathParts.join(" | "); } /** * Enhanced querySelector that uses XPath for better namespace handling * (Drop-in replacement for the original querySelector function) * * @param parent The parent element or document to search within * @param selector A CSS-like selector (with namespace handling) * @returns The found element or null */ function querySelector(parent, selector) { // First try native querySelector if we're in a browser if (typeof parent.querySelector === "function") { try { const result = parent.querySelector(selector); if (result) return result; } catch (e) { // Fallback to XPath if querySelector fails (e.g., due to namespace issues) } } // First try with our enhanced DOM traversal methods (more reliable in some cases) const elements = findElementsByTagNameRecursive(parent, selector); if (elements.length > 0) { return elements[0]; } // Then try XPath as a fallback try { const xpath = selectorToXPath(selector); return queryByXPath(parent, xpath); } catch (e) { console.warn("XPath query failed, using direct DOM traversal as fallback"); return null; } } /** * Enhanced querySelectorAll that uses XPath for better namespace handling * (Drop-in replacement for the original querySelectorAll function) * * @param parent The parent element or document to search within * @param selector A CSS-like selector (with namespace handling) * @returns Array of matching elements */ function querySelectorAll(parent, selector) { // First try native querySelectorAll if we're in a browser if (typeof parent.querySelectorAll === "function") { try { const results = parent.querySelectorAll(selector); if (results.length > 0) { const elements = []; for (let i = 0; i < results.length; i++) { elements.push(results[i]); } return elements; } } catch (e) { // Fallback to XPath if querySelectorAll fails (e.g., due to namespace issues) } } // First try with our enhanced DOM traversal methods (more reliable in some cases) const elements = findElementsByTagNameRecursive(parent, selector); if (elements.length > 0) { return elements; } // Then try XPath as a fallback try { const xpath = selectorToXPath(selector); return queryAllByXPath(parent, xpath); } catch (e) { console.warn("XPath query failed, using direct DOM traversal as fallback"); return []; } } /** * Serialize a DOM node to XML string */ function serializeToXML(node) { // Check if we're in a browser environment with native XMLSerializer if (typeof window !== "undefined" && window.XMLSerializer) { return new window.XMLSerializer().serializeToString(node); } // If we're using xmldom try { const { XMLSerializer } = require("@xmldom/xmldom"); return new XMLSerializer().serializeToString(node); } catch (e) { throw new Error("XML Serializer not available. In Node.js environments, please install @xmldom/xmldom package."); } } // Canonicalization method URIs const CANONICALIZATION_METHODS = { default: "c14n", "http://www.w3.org/TR/2001/REC-xml-c14n-20010315": "c14n", "http://www.w3.org/2006/12/xml-c14n11": "c14n11", "http://www.w3.org/2001/10/xml-exc-c14n#": "c14n_exc", }; // Internal method implementations const methods = { c14n: { beforeChildren: () => "", afterChildren: () => "", betweenChildren: () => "", afterElement: () => "", isCanonicalizationMethod: "c14n", }, c14n11: { // C14N 1.1 should NOT add newlines - it should preserve original whitespace // The difference from C14N is in xml:id normalization, not formatting beforeChildren: () => "", afterChildren: () => "", betweenChildren: () => "", afterElement: () => "", isCanonicalizationMethod: "c14n11", }, c14n_exc: { beforeChildren: () => "", afterChildren: () => "", betweenChildren: () => "", afterElement: () => "", isCanonicalizationMethod: "c14n_exc", }, }; // Define these constants as they're used in the code const NODE_TYPES = { ELEMENT_NODE: 1, TEXT_NODE: 3, }; class XMLCanonicalizer { constructor(method = methods.c14n) { this.method = method; } // Static method to get canonicalizer by URI static fromMethod(methodUri) { const methodKey = CANONICALIZATION_METHODS[methodUri]; if (!methodKey) { throw new Error(`Unsupported canonicalization method: ${methodUri}`); } return new XMLCanonicalizer(methods[methodKey]); } setMethod(method) { this.method = method; } static escapeXml(text) { return text .replace(/&/g, "&amp;") .replace(/</g, "&lt;") .replace(/>/g, "&gt;") .replace(/"/g, "&quot;") .replace(/'/g, "&apos;"); } // Helper method to collect namespaces from ancestors static collectNamespaces(node, visibleNamespaces = new Map()) { let current = node; while (current && current.nodeType === NODE_TYPES.ELEMENT_NODE) { const element = current; // Handle default namespace const xmlnsAttr = element.getAttribute("xmlns"); if (xmlnsAttr !== null && !visibleNamespaces.has("")) { visibleNamespaces.set("", xmlnsAttr); } // Handle prefixed namespaces const attrs = element.attributes; for (let i = 0; i < attrs.length; i++) { const attr = attrs[i]; if (attr.name.startsWith("xmlns:")) { const prefix = attr.name.substring(6); if (!visibleNamespaces.has(prefix)) { visibleNamespaces.set(prefix, attr.value); } } } current = current.parentNode; } return visibleNamespaces; } // Helper method to collect namespaces used in the specific element and its descendants static collectUsedNamespaces(node, allVisibleNamespaces = new Map(), inclusivePrefixList = []) { const usedNamespaces = new Map(); const visitedPrefixes = new Set(); // Track prefixes we've already processed // Recursive function to check for namespace usage function processNode(currentNode, isRoot = false) { if (currentNode.nodeType === NODE_TYPES.ELEMENT_NODE) { const element = currentNode; // Check element's namespace const elementNs = element.namespaceURI; const elementPrefix = element.prefix || ""; if (elementPrefix && elementNs) { // If this is the root element or a prefix we haven't seen yet if (isRoot || !visitedPrefixes.has(elementPrefix)) { visitedPrefixes.add(elementPrefix); // If the namespace URI matches what we have in allVisibleNamespaces for this prefix const nsUri = allVisibleNamespaces.get(elementPrefix); if (nsUri && nsUri === elementNs && !usedNamespaces.has(elementPrefix)) { usedNamespaces.set(elementPrefix, nsUri); } } } // Check attributes for namespaces const attrs = element.attributes; for (let i = 0; i < attrs.length; i++) { const attr = attrs[i]; if (attr.name.includes(":") && !attr.name.startsWith("xmlns:")) { const attrPrefix = attr.name.split(":")[0]; // Only process this prefix if we haven't seen it before or it's the root element if (isRoot || !visitedPrefixes.has(attrPrefix)) { visitedPrefixes.add(attrPrefix); const nsUri = allVisibleNamespaces.get(attrPrefix); if (nsUri && !usedNamespaces.has(attrPrefix)) { usedNamespaces.set(attrPrefix, nsUri); } } } } // Include namespaces from inclusivePrefixList for (const prefix of inclusivePrefixList) { const nsUri = allVisibleNamespaces.get(prefix); if (nsUri && !usedNamespaces.has(prefix)) { usedNamespaces.set(prefix, nsUri); } } // Process child nodes for (let i = 0; i < currentNode.childNodes.length; i++) { processNode(currentNode.childNodes[i], false); } } } processNode(node, true); // Start with root = true return usedNamespaces; } static isBase64Element(node) { if (node.nodeType !== NODE_TYPES.ELEMENT_NODE) return false; const element = node; const localName = element.localName || element.nodeName.split(":").pop() || ""; return this.base64Elements.has(localName); } // Method to analyze whitespace in document static analyzeWhitespace(node) { // If node is a document, use the document element const rootNode = node.nodeType === NODE_TYPES.ELEMENT_NODE ? node : node.documentElement; function analyzeNode(node) { if (node.nodeType === NODE_TYPES.ELEMENT_NODE) { // Initialize whitespace info node._whitespace = { hasMixedContent: false, hasExistingLinebreaks: false, originalContent: {}, }; const children = Array.from(node.childNodes); let hasTextContent = false; let hasElementContent = false; let hasLinebreaks = false; // First, check if there's any non-whitespace text content for (const child of children) { if (child.nodeType === NODE_TYPES.TEXT_NODE) { const text = child.nodeValue || ""; if (text.trim().length > 0) { hasTextContent = true; break; } } } // Second, check if there are any element children for (const child of children) { if (child.nodeType === NODE_TYPES.ELEMENT_NODE) { hasElementContent = true; break; } } // Now process all children and analyze recursively for (let i = 0; i < children.length; i++) { const child = children[i]; if (child.nodeType === NODE_TYPES.TEXT_NODE) { const text = child.nodeValue || ""; // Store original text child._originalText = text; // Check for linebreaks in text if (text.includes("\n")) { hasLinebreaks = true; } } else if (child.nodeType === NODE_TYPES.ELEMENT_NODE) { // Recursively analyze child elements analyzeNode(child); } } // Set mixed content flag - true if there's both text content and element children node._whitespace.hasMixedContent = hasTextContent && hasElementContent; node._whitespace.hasExistingLinebreaks = hasLinebreaks; } } analyzeNode(rootNode); } // Standard canonicalization method canonicalize(node, visibleNamespaces = new Map(), options = { isStartingNode: true }) { if (!node) return ""; let result = ""; if (node.nodeType === NODE_TYPES.ELEMENT_NODE) { // Create a new map for this element's visible namespaces const elementVisibleNamespaces = new Map(visibleNamespaces); const element = node; // Collect namespaces declared on this element // Handle default namespace const xmlnsAttr = element.getAttribute("xmlns"); if (xmlnsAttr !== null) { elementVisibleNamespaces.set("", xmlnsAttr); } // Handle prefixed namespaces const nsAttrs = element.attributes; for (let i = 0; i < nsAttrs.length; i++) { const attr = nsAttrs[i]; if (attr.name.startsWith("xmlns:")) { const prefix = attr.name.substring(6); elementVisibleNamespaces.set(prefix, attr.value); } } // Prepare the element's start tag const prefix = element.prefix || ""; const localName = element.localName || element.nodeName.split(":").pop() || ""; const qName = prefix ? `${prefix}:${localName}` : localName; result += "<" + qName; // Handle namespaces based on whether it's the starting node if (options.isStartingNode) { // Collect all namespaces in scope for this element const allNamespaces = XMLCanonicalizer.collectNamespaces(node); // Include all namespaces that are in scope, sorted appropriately const nsEntries = Array.from(allNamespaces.entries()).sort((a, b) => { if (a[0] === "") return -1; if (b[0] === "") return 1; return a[0].localeCompare(b[0]); }); for (const [prefix, uri] of nsEntries) { if (prefix === "") { result += ` xmlns="${uri}"`; } else { result += ` xmlns:${prefix}="${uri}"`; } } } else { // For non-starting nodes, only include newly declared namespaces const nsEntries = Array.from(elementVisibleNamespaces.entries()) .filter(([p, uri]) => { // Include if: // 1. It's not in the parent's visible namespaces, or // 2. The URI is different from parent's return !visibleNamespaces.has(p) || visibleNamespaces.get(p) !== uri; }) .sort((a, b) => { if (a[0] === "") return -1; if (b[0] === "") return 1; return a[0].localeCompare(b[0]); }); for (const [prefix, uri] of nsEntries) { if (prefix === "") { result += ` xmlns="${uri}"`; } else { result += ` xmlns:${prefix}="${uri}"`; } } } // Handle attributes (sorted lexicographically) const elementAttrs = element.attributes; const attrArray = []; for (let i = 0; i < elementAttrs.length; i++) { const attr = elementAttrs[i]; if (!attr.name.startsWith("xmlns")) { attrArray.push(attr); } } attrArray.sort((a, b) => a.name.localeCompare(b.name)); for (const attr of attrArray) { result += ` ${attr.name}="${XMLCanonicalizer.escapeXml(attr.value)}"`; } result += ">"; // Process children const children = Array.from(node.childNodes); let hasElementChildren = false; let lastWasElement = false; const hasMixedContent = node._whitespace?.hasMixedContent || false; // First pass to determine if we have element children for (const child of children) { if (child.nodeType === NODE_TYPES.ELEMENT_NODE) { hasElementChildren = true; break; } } // Check if we need to add a newline for c14n11 // Don't add newlines for mixed content const needsInitialNewline = this.method.isCanonicalizationMethod === "c14n11" && hasElementChildren && !node._whitespace?.hasExistingLinebreaks && !hasMixedContent; // Add newline for c14n11 if needed if (needsInitialNewline) { result += this.method.beforeChildren(hasElementChildren, hasMixedContent); } // Process each child for (let i = 0; i < children.length; i++) { const child = children[i]; const isElement = child.nodeType === NODE_TYPES.ELEMENT_NODE; const nextChild = i < children.length - 1 ? children[i + 1] : null; nextChild && nextChild.nodeType === NODE_TYPES.ELEMENT_NODE; // Handle text node if (child.nodeType === NODE_TYPES.TEXT_NODE) { const text = child.nodeValue || ""; if (XMLCanonicalizer.isBase64Element(node)) { // Special handling for base64 content result += text.replace(/\r/g, "&#xD;"); } else { // Use the original text exactly as it was result += child._originalText || text; } lastWasElement = false; continue; } // Handle element node if (isElement) { // Add newline between elements if needed for c14n11 // Don't add newlines for mixed content if (lastWasElement && this.method.isCanonicalizationMethod === "c14n11" && !node._whitespace?.hasExistingLinebreaks && !hasMixedContent) { result += this.method.betweenChildren(true, true, hasMixedContent); } // Recursively canonicalize the child element result += this.canonicalize(child, elementVisibleNamespaces, { isStartingNode: false, }); lastWasElement = true; } } // Add final newline for c14n11 if needed // Don't add newlines for mixed content if (needsInitialNewline) { result += this.method.afterChildren(hasElementChildren, hasMixedContent); } result += "</" + qName + ">"; } else if (node.nodeType === NODE_TYPES.TEXT_NODE) { // For standalone text nodes const text = node._originalText || node.nodeValue || ""; result += XMLCanonicalizer.escapeXml(text); } return result; } // Exclusive canonicalization implementation canonicalizeExclusive(node, visibleNamespaces = new Map(), options = {}) { if (!node) return ""; const { inclusiveNamespacePrefixList = [], isStartingNode = true } = options; let result = ""; if (node.nodeType === NODE_TYPES.ELEMENT_NODE) { const element = node; // First, collect all namespaces that are visible at this point const allVisibleNamespaces = XMLCanonicalizer.collectNamespaces(element); // Then, determine which namespaces are actually used in this subtree const usedNamespaces = isStartingNode ? XMLCanonicalizer.collectUsedNamespaces(element, allVisibleNamespaces, inclusiveNamespacePrefixList) : new Map(); // For child elements, don't add any more namespaces // Start the element opening tag const prefix = element.prefix || ""; const localName = element.localName || element.nodeName.split(":").pop() || ""; const qName = prefix ? `${prefix}:${localName}` : localName; result += "<" + qName; // Add namespace declarations for used namespaces (only at the top level) if (isStartingNode) { const nsEntries = Array.from(usedNamespaces.entries()).sort((a, b) => { if (a[0] === "") return -1; if (b[0] === "") return 1; return a[0].localeCompare(b[0]); }); for (const [prefix, uri] of nsEntries) { if (prefix === "") { result += ` xmlns="${uri}"`; } else { result += ` xmlns:${prefix}="${uri}"`; } } } // Add attributes (sorted lexicographically) const elementAttrs = element.attributes; const attrArray = []; for (let i = 0; i < elementAttrs.length; i++) { const attr = elementAttrs[i]; if (!attr.name.startsWith("xmlns")) { attrArray.push(attr); } } attrArray.sort((a, b) => a.name.localeCompare(b.name)); for (const attr of attrArray) { result += ` ${attr.name}="${XMLCanonicalizer.escapeXml(attr.value)}"`; } result += ">"; // Process child nodes const children = Array.from(node.childNodes); for (let i = 0; i < children.length; i++) { const child = children[i]; if (child.nodeType === NODE_TYPES.TEXT_NODE) { const text = child.nodeValue || ""; if (XMLCanonicalizer.isBase64Element(node)) { // Special handling for base64 content result += text.replace(/\r/g, "&#xD;"); } else { // Regular text handling result += XMLCanonicalizer.escapeXml(text); } } else if (child.nodeType === NODE_TYPES.ELEMENT_NODE) { // Recursively process child elements // For child elements, we pass the namespaces from the parent but mark as non-root result += this.canonicalizeExclusive(child, new Map([...visibleNamespaces, ...usedNamespaces]), // Pass all namespaces to children { inclusiveNamespacePrefixList, isStartingNode: false, // Mark as non-starting node }); } } // Close the element result += "</" + qName + ">"; } else if (node.nodeType === NODE_TYPES.TEXT_NODE) { // Handle standalone text node const text = node.nodeValue || ""; result += XMLCanonicalizer.escapeXml(text); } return result; } // Static methods for canonicalization static c14n(node) { // First analyze document whitespace this.analyzeWhitespace(node); // Then create canonicalizer and process the node const canonicalizer = new XMLCanonicalizer(methods.c14n); return canonicalizer.canonicalize(node); } static c14n11(node) { // First analyze document whitespace this.analyzeWhitespace(node); // Then create canonicalizer and process the node const canonicalizer = new XMLCanonicalizer(methods.c14n11); return canonicalizer.canonicalize(node); } static c14n_exc(node, inclusiveNamespacePrefixList = []) { // First analyze document whitespace this.analyzeWhitespace(node); // Create canonicalizer and process the node with exclusive canonicalization const canonicalizer = new XMLCanonicalizer(methods.c14n_exc); return canonicalizer.canonicalizeExclusive(node, new Map(), { inclusiveNamespacePrefixList, }); } // Method that takes URI directly static canonicalize(node, methodUri, options = {}) { // Get the method from the URI const methodKey = CANONICALIZATION_METHODS[methodUri] || CANONICALIZATION_METHODS.default; switch (methodKey) { case "c14n": return this.c14n(node); case "c14n11": return this.c14n11(node); case "c14n_exc": return this.c14n_exc(node, options.inclusiveNamespacePrefixList || []); default: throw new Error(`Unsupported canonicalization method: ${methodUri}`); } } } XMLCanonicalizer.base64Elements = new Set([ "DigestValue", "X509Certificate", "EncapsulatedTimeStamp", "EncapsulatedOCSPValue", "IssuerSerialV2", ]); /** * Format a certificate string as a proper PEM certificate * @param certBase64 Base64-encoded certificate * @returns Formatted PEM certificate */ function formatPEM$1(certBase64) { if (!certBase64) return ""; // Remove any whitespace from the base64 string const cleanBase64 = certBase64.replace(/\s+/g, ""); // Split the base64 into lines of 64 characters const lines = []; for (let i = 0; i < cleanBase64.length; i += 64) { lines.push(cleanBase64.substring(i, i + 64)); } // Format as PEM certificate return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----`; } /** * Extract subject information from an X.509 certificate * @param certificate X509Certificate instance * @returns Signer information object */ function extractSignerInfo(certificate) { const result = { validFrom: certificate.notBefore, validTo: certificate.notAfter, issuer: {}, }; // Try to extract fields using various approaches // Approach 1: Try direct access to typed subject properties try { if (typeof certificate.subject === "object" && certificate.subject !== null) { // Handle subject properties const subject = certificate.subject; result.commonName = subject.commonName; result.organization = subject.organizationName; result.country = subject.countryName; } // Handle issuer properties if (typeof certificate.issuer === "object" && certificate.issuer !== null) { const issuer = certificate.issuer; result.issuer.commonName = issuer.commonName; result.issuer.organization = issuer.organizationName; result.issuer.country = issuer.countryName; } } catch (e) { console.warn("Could not extract subject/issuer as objects:", e); } // Approach 2: Parse subject/issuer as strings if they are strings try { if (typeof certificate.subject === "string") { const subjectStr = certificate.subject; // Parse the string format (usually CN=name,O=org,C=country) const subjectParts = subjectStr.split(","); for (const part of subjectParts) { const [key, value] = part.trim().split("="); if (key === "CN") result.commonName = result.commonName || value; if (key === "O") result.organization = result.organization || value; if (key === "C") result.country = result.country || value; if (key === "SN") result.surname = value; if (key === "G" || key === "GN") result.givenName = value; if (key === "SERIALNUMBER" || key === "2.5.4.5") result.serialNumber = value?.replace("PNOLV-", ""); } } if (typeof certificate.issuer === "string") { const issuerStr = certificate.issuer; // Parse the string format const issuerParts = issuerStr.split(","); for (const part of issuerParts) { const [key, value] = part.trim().split("="); if (key === "CN") result.issuer.commonName = result.issuer.commonName || value; if (key === "O") result.issuer.organization = result.issuer.organization || value; if (key === "C") result.issuer.country = result.issuer.country || value; } } } catch (e) { console.warn("Could not extract subject/issuer as strings:", e); } // Approach 3: Try to use getField method if available try { if ("subjectName" in certificate && certificate.subjectName?.getField) { const subjectName = certificate.subjectName; // Only set if not already set from previous approaches result.commonName = result.commonName || subjectName.getField("CN")?.[0]; result.surname = result.surname || subjectName.getField("SN")?.[0]; result.givenName = result.givenName || subjectName.getField("G")?.[0]; result.serialNumber = result.serialNumber || subjectName.getField("2.5.4.5")?.[0]?.replace("PNOLV-", ""); result.country = result.country || subjectName.getField("C")?.[0]; result.organization = result.organization || subjectName.getField("O")?.[0]; } } catch (e) { console.warn("Could not extract fields using getField method:", e); } // Get the serial number from the certificate if not found in subject if (!result.serialNumber && certificate.serialNumber) { result.serialNumber = certificate.serialNumber; } return result; } /** * Parse a certificate from base64 data * @param certData Base64-encoded certificate data * @returns Parsed certificate information */ async function parseCertificate(certData) { try { let pemCert = certData; // Check if it's already in PEM format, if not, convert it if (!certData.includes("-----BEGIN CERTIFICATE-----")) { // Only clean non-PEM format data before conversion const cleanedCertData = certData.replace(/[\r\n\s]/g, ""); pemCert = formatPEM$1(cleanedCertData); } const cert = new x509.X509Certificate(pemCert); const signerInfo = extractSignerInfo(cert); return { subject: { commonName: signerInfo.commonName, organization: signerInfo.organization, country: signerInfo.country, surname: signerInfo.surname, givenName: signerInfo.givenName, serialNumber: signerInfo.serialNumber, }, validFrom: signerInfo.validFrom, validTo: signerInfo.validTo, issuer: signerInfo.issuer, serialNumber: cert.serialNumber, }; } catch (error) { console.error("Certificate parsing error:", error); throw new Error("Failed to parse certificate: " + (error instanceof Error ? error.message : String(error))); } } /** * Check if a certificate was valid at a specific time * @param cert Certificate object or info * @param checkTime The time to check validity against (defaults to current time) * @returns Validity check result */ function checkCertificateValidity(cert, checkTime = new Date()) { // Extract validity dates based on input type const validFrom = "notBefore" in cert ? cert.notBefore : cert.validFrom; const validTo = "notAfter" in cert ? cert.notAfter : cert.validTo; // Check if certificate is valid at the specified time if (checkTime < validFrom) { return { isValid: false, reason: `Certificate not yet valid. Valid from ${validFrom.toISOString()}`, }; } if (checkTime > validTo) { return { isValid: false, reason: `Certificate expired. Valid until ${validTo.toISOString()}`, }; } return { isValid: true }; } /** * Helper function to get signer display name from certificate * @param certInfo Certificate information * @returns Formatted display name */ function getSignerDisplayName(certInfo) { const { subject } = certInfo; if (subject.givenName && subject.surname) { return `${subject.givenName} ${subject.surname}`; } if (subject.commonName) { return subject.commonName; } // Fallback to serial number if available return subject.serialNumber || "Unknown Signer"; } /** * Helper function to format certificate validity period in a human-readable format * @param certInfo Certificate information * @returns Formatted validity period */ function formatValidityPeriod(certInfo) { const { validFrom, validTo } = certInfo; const formatDate = (date) => { return date.toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric", }); }; return `${formatDate(validFrom)} to ${formatDate(validTo)}`; } // src/core/parser/certificateUtils.ts /** * Format a certificate string as a proper PEM certificate * @param certBase64 Base64-encoded certificate * @returns Formatted PEM certificate */ function formatPEM(certBase64) { if (!certBase64) return ""; // Remove any whitespace from the base64 string const cleanBase64 = certBase64.replace(/\s+/g, ""); // Split the base64 into lines of 64 characters const lines = []; for (let i = 0; i < cleanBase64.length; i += 64) { lines.push(cleanBase64.substring(i, i + 64)); } // Format as PEM certificate return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----`; } // src/core/parser/signatureParser.ts /** * Find signature files in the eDoc container * @param files Map of filenames to file contents * @returns Array of signature filenames */ function findSignatureFiles(files) { // Signature files are typically named with patterns like: // - signatures0.xml // - META-INF/signatures*.xml return Array.from(files.keys()).filter((filename) => filename.match(/META-INF\/signatures\d*\.xml$/) || filename.match(/META-INF\/.*signatures.*\.xml$/i)); } /** * Parse a signature file that contains a single signature * @param xmlContent The XML file content * @param filename The filename (for reference) * @returns The parsed signature with raw XML content */ function parseSignatureFile(xmlContent, filename) { const text = new TextDecoder().decode(xmlContent); const parser = createXMLParser(); const xmlDoc = parser.parseFromString(text, "application/xml"); // Using our querySelector helper to find the signature element const signatureElements = querySelectorAll(xmlDoc, "ds\\:Signature, Signature"); if (signatureElements.length === 0) { console.warn(`No Signature elements found in ${filename}`); // If we have ASiC-XAdES format, try to find signatures differently if (text.includes("XAdESSignatures")) { const rootElement = xmlDoc.documentElement; // Try direct DOM traversal if (rootElement) { // Look for Signature elements as direct children const directSignature = querySelector(rootElement, "ds\\:Signature, Signature"); if (directSignature) { let signatureInfo = parseSignatureElement(directSignature, xmlDoc); signatureInfo.rawXml = text; return signatureInfo; } } // Fallback: parse as text const mockSignature = parseBasicSignatureFromText(text); if (mockSignature) { return { ...mockSignature, rawXml: text, }; } } return null; } // Parse the signature and add the raw XML let signatureInfo = parseSignatureElement(signatureElements[0], xmlDoc); signatureInfo.rawXml = text; return signatureInfo; } /** * Parse a single signature element using a browser-like approach * @param signatureElement The signature element to parse * @param xmlDoc The parent XML document * @returns Parsed signature information */ function parseSignatureElement(signatureElement, xmlDoc) { // Get signature ID const signatureId = signatureElement.getAttribute("Id") || "unknown"; // Find SignedInfo just like in browser code const signedInfo = querySelector(signatureElement, "ds\\:SignedInfo, SignedInfo"); //const signedInfo = queryByXPath(signatureElement, ".//*[local-name()='SignedInfo']"); if (!signedInfo) { throw new Error("SignedInfo element not found"); } // Get the canonicalization method const c14nMethodEl = querySelector(signedInfo, "ds\\:CanonicalizationMethod, CanonicalizationMethod"); let canonicalizationMethod = CANONICALIZATION_METHODS.default; if (c14nMethodEl) { canonicalizationMethod = c14nMethodEl.getAttribute("Algorithm") || canonicalizationMethod; } // // Serialize the SignedInfo element to XML string // let signedInfoXml = ""; // try { // // Use the serializeToXML utility function which handles browser/Node environments // signedInfoXml = serializeToXML(signedInfo); // } catch (e) { // console.warn("Could not serialize SignedInfo element:", e); // } // Serialize the SignedInfo element to XML string let signedInfoXml = ""; signedInfoXml = serializeToXML(signedInfo); // try { // // First check if signedInfo is a valid node // if (!signedInfo) { // console.warn("SignedInfo element is undefined or null"); // signedInfoXml = ""; // } else if (