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,175 lines (1,170 loc) • 413 kB
JavaScript
/*!
* MIT License
* Copyright (c) 2025 Edgars Jēkabsons, ZenomyTech SIA
*/
import { unzipSync } from 'fflate';
import { X509Certificate, X509Crl } from '@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, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// 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, "
");
}
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, "
");
}
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 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 (signedInfo.nodeType === undefined) {
// console.war