diff --git a/WEBCRYPTO.md b/WEBCRYPTO.md new file mode 100644 index 00000000..b3c56996 --- /dev/null +++ b/WEBCRYPTO.md @@ -0,0 +1,281 @@ +# WebCrypto Support + +This library now supports the Web Crypto API, which allows it to run in browsers and modern Node.js environments **without any Node.js-specific dependencies** for cryptographic operations. + +## Overview + +The WebCrypto implementation provides: + +- **Browser compatibility**: Run XML signing and verification in the browser +- **No Node.js crypto dependency**: Uses the standard Web Crypto API +- **Callback-based async operations**: WebCrypto operations use callbacks for async handling +- **Same API structure**: Uses the same methods as Node.js crypto, just with callbacks + +## Supported Algorithms + +### Hash Algorithms + +- `WebCryptoSha1` - SHA-1 hashing +- `WebCryptoSha256` - SHA-256 hashing +- `WebCryptoSha512` - SHA-512 hashing + +### Signature Algorithms + +- `WebCryptoRsaSha1` - RSA-SHA1 signing/verification +- `WebCryptoRsaSha256` - RSA-SHA256 signing/verification +- `WebCryptoRsaSha512` - RSA-SHA512 signing/verification +- `WebCryptoHmacSha1` - HMAC-SHA1 signing/verification + +## Usage + +### Basic Example - Signing (Browser or Node.js with WebCrypto) + +```javascript +import { SignedXml, WebCryptoRsaSha256, WebCryptoSha256 } from "xml-crypto"; + +// Your XML to sign +const xml = "Hello World"; + +// Your private key (PEM format) +const privateKey = `-----BEGIN PRIVATE KEY----- +... +-----END PRIVATE KEY-----`; + +// Create SignedXml instance +const sig = new SignedXml(); + +// Use WebCrypto algorithms +sig.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; +sig.privateKey = privateKey; + +// Add reference with WebCrypto hash algorithm +sig.addReference({ + xpath: "//*[local-name(.)='data']", + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], +}); + +// Register WebCrypto algorithms +sig.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; +sig.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = WebCryptoRsaSha256; + +// Compute signature with callback +sig.computeSignature(xml, (err, signedXmlObj) => { + if (err) { + console.error("Signing failed:", err); + return; + } + + console.log(signedXmlObj.getSignedXml()); +}); +``` + +### Verifying a Signature + +```javascript +import { SignedXml, WebCryptoRsaSha256, WebCryptoSha256 } from "xml-crypto"; +import { DOMParser } from "@xmldom/xmldom"; + +const signedXml = `...`; // Your signed XML + +const sig = new SignedXml(); + +// Register WebCrypto algorithms +sig.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; +sig.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = WebCryptoRsaSha256; + +// Provide public key or certificate (SPKI format) +sig.publicCert = publicKey; + +// Load the signature - need to extract it from the signed XML first +const doc = new DOMParser().parseFromString(signedXml); +const signature = sig.findSignatures(doc)[0]; +sig.loadSignature(signature); + +// Verify with callback +sig.checkSignature(signedXml, (err, isValid) => { + if (err) { + console.error("Verification failed:", err); + return; + } + console.log("Signature valid:", isValid); +}); +``` + +## Key Format Conversion + +The WebCrypto algorithms accept keys in PEM format (strings) and will automatically convert them to `CryptoKey` objects internally. + +**Note**: For verification, WebCrypto requires public keys in SPKI format, not X.509 certificates. See the X.509 Certificates section below for how to extract the public key. + +## Using Callbacks with WebCrypto + +Both `computeSignature` and `checkSignature` support an optional callback parameter. When using WebCrypto algorithms, you **must** provide a callback to handle the asynchronous operations: + +```javascript +// Signing with callback +sig.computeSignature(xml, (err, signedXmlObj) => { + if (err) { + console.error("Error:", err); + return; + } + // signedXmlObj is the SignedXml instance + const result = signedXmlObj.getSignedXml(); +}); + +// Verification with callback +sig.checkSignature(signedXml, (err, isValid) => { + if (err) { + console.error("Error:", err); + return; + } + console.log("Valid:", isValid); +}); +``` + +**Important**: If you try to use WebCrypto algorithms without providing a callback, the operation will fail because WebCrypto operations are inherently asynchronous. + +## Browser Compatibility + +The WebCrypto API is supported in all modern browsers: + +- Chrome/Edge 37+ +- Firefox 34+ +- Safari 11+ + +## Node.js Compatibility + +WebCrypto is available in Node.js 15.0.0+ via the global `crypto.subtle` object. For older Node.js versions, continue using the standard crypto-based algorithms. + +## Migration from Node.js Crypto + +To migrate from Node.js crypto to WebCrypto: + +1. Change algorithm imports: + + ```javascript + // Before + import { Sha256, RsaSha256 } from "xml-crypto"; + + // After + import { WebCryptoSha256, WebCryptoRsaSha256 } from "xml-crypto"; + ``` + +2. Update to use callbacks: + + ```javascript + // Before (synchronous) + sig.computeSignature(xml); + const result = sig.getSignedXml(); + + // After (with callback) + sig.computeSignature(xml, (err, signedXmlObj) => { + if (err) { + console.error(err); + return; + } + const result = signedXmlObj.getSignedXml(); + }); + ``` + +3. Register algorithms: + ```javascript + sig.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + sig.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = + WebCryptoRsaSha256; + ``` + +## Limitations + +1. **X.509 Certificates**: The Web Crypto API doesn't directly support X.509 certificates. If you have a certificate, you need to extract the public key in SPKI format first: + + ```javascript + // In Node.js, you can extract it like this: + import { createPublicKey } from "crypto"; + + const publicKey = createPublicKey(certificatePem); + const spkiPublicKey = publicKey.export({ + type: "spki", + format: "pem", + }); + + // Use spkiPublicKey with WebCrypto algorithms + sig.publicCert = spkiPublicKey; + ``` + + In browsers, you'll need to prepare the keys in SPKI format beforehand or use a library to parse X.509 certificates. + +2. **PEM/DER parsing**: The utility functions provide basic PEM parsing. +3. **Key formats**: Only PKCS8 private keys and SPKI public keys are currently supported for RSA. +4. **Callback requirement**: All WebCrypto operations require callbacks - you cannot use them with the synchronous API (without a callback). + +## Benefits + +- **Zero dependencies on Node.js crypto**: Run in any environment that supports Web Crypto API +- **Browser support**: Enable XML signing/verification in web applications +- **Standard API**: Uses the widely-supported Web Crypto API standard +- **Future-proof**: Web Crypto is the modern standard for cryptography on the web + +## Example: Complete Sign and Verify Flow + +```javascript +import { SignedXml, WebCryptoRsaSha256, WebCryptoSha256 } from "xml-crypto"; +import { DOMParser } from "@xmldom/xmldom"; + +function signAndVerify(callback) { + const xml = "Important data"; + + // Signing + const sigForSigning = new SignedXml(); + sigForSigning.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + sigForSigning.privateKey = privateKeyPem; + sigForSigning.addReference({ + xpath: "//*[local-name(.)='data']", + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + // Register algorithms + sigForSigning.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + sigForSigning.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = + WebCryptoRsaSha256; + + sigForSigning.computeSignature(xml, (err, signedXmlObj) => { + if (err) { + return callback(err); + } + + const signedXml = signedXmlObj.getSignedXml(); + + // Verification + const sigForVerifying = new SignedXml(); + sigForVerifying.publicCert = publicKeyPem; // SPKI format + + // Register algorithms for verification + sigForVerifying.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + sigForVerifying.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = + WebCryptoRsaSha256; + + // Load the signature + const doc = new DOMParser().parseFromString(signedXml); + const signature = sigForVerifying.findSignatures(doc)[0]; + sigForVerifying.loadSignature(signature); + + sigForVerifying.checkSignature(signedXml, (err, isValid) => { + if (err) { + return callback(err); + } + console.log("Signature is valid:", isValid); + callback(null, isValid); + }); + }); +} + +signAndVerify((err, isValid) => { + if (err) { + console.error("Error:", err); + } else { + console.log("Success! Valid:", isValid); + } +}); +``` diff --git a/example/webcrypto-example.js b/example/webcrypto-example.js new file mode 100644 index 00000000..58d6b2c1 --- /dev/null +++ b/example/webcrypto-example.js @@ -0,0 +1,136 @@ +/** + * Example of using xml-crypto with Web Crypto API (callback-based) + * + * This example demonstrates how to use the WebCrypto implementations + * to sign and verify XML signatures using callbacks. + * + * This works in: + * - Modern browsers + * - Node.js 15.0.0+ + * - Deno + * - Any environment with Web Crypto API support + */ + +import { SignedXml, WebCryptoRsaSha256, WebCryptoSha256 } from "../lib/index.js"; +import { readFileSync } from "fs"; +import { createPublicKey } from "crypto"; +import { DOMParser } from "@xmldom/xmldom"; + +/** + * Helper function to convert X.509 certificate to SPKI format public key + * Note: This uses Node.js crypto for conversion. In a pure browser environment, + * you would need to extract the public key beforehand or use a library. + */ +function extractPublicKeyFromCertificate(certPem) { + try { + const publicKey = createPublicKey(certPem); + return publicKey.export({ + type: "spki", + format: "pem", + }); + } catch (error) { + throw new Error(`Failed to extract public key from certificate: ${error.message}`); + } +} + +function signXml(callback) { + console.log("=== Signing XML with WebCrypto ===\n"); + + const xml = "Harry Potter"; + console.log("Original XML:", xml); + + // Load private key + const privateKey = readFileSync("./example/client.pem", "utf8"); + + // Create signature + const sig = new SignedXml(); + sig.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + sig.privateKey = privateKey; + + // Add reference + sig.addReference({ + xpath: "//*[local-name(.)='book']", + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + // Register WebCrypto algorithms + sig.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + sig.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = WebCryptoRsaSha256; + + // Compute signature with callback + sig.computeSignature(xml, (err, signedXmlObj) => { + if (err) { + return callback(err); + } + + const signedXml = signedXmlObj.getSignedXml(); + console.log("\nSigned XML:", signedXml); + callback(null, signedXml); + }); +} + +function verifyXml(signedXml, callback) { + console.log("\n=== Verifying XML Signature with WebCrypto ===\n"); + + // Load public certificate and extract the public key in SPKI format + const certPem = readFileSync("./example/client_public.pem", "utf8"); + const publicKeySpki = extractPublicKeyFromCertificate(certPem); + + console.log("Note: Extracted public key from X.509 certificate"); + + // Create verification object + const sig = new SignedXml(); + sig.publicCert = publicKeySpki; // Use SPKI format public key + + // Register WebCrypto algorithms + sig.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + sig.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = WebCryptoRsaSha256; + + // We need to load the signature before verification: + // 1. Parse the signed XML to get a DOM + // 2. Find the element within it + // 3. Load that specific signature node + const doc = new DOMParser().parseFromString(signedXml); + const signature = sig.findSignatures(doc)[0]; + sig.loadSignature(signature); + + // Verify with callback + sig.checkSignature(signedXml, (err, isValid) => { + if (err) { + console.error("Signature verification failed:", err.message); + return callback(err); + } + console.log("Signature is valid:", isValid); + callback(null, isValid); + }); +} + +function main() { + // Sign the XML + signXml((err, signedXml) => { + if (err) { + console.error("\n❌ Error during signing:", err); + process.exit(1); + } + + // Verify the signature + verifyXml(signedXml, (err, isValid) => { + if (err) { + console.error("\n❌ Error during verification:", err); + process.exit(1); + } + + if (isValid) { + console.log("\n✅ Success! XML was signed and verified using WebCrypto API"); + } else { + console.log("\n❌ Verification failed"); + process.exit(1); + } + }); + }); +} + +// Run the example +main(); diff --git a/src/hash-algorithms-webcrypto.ts b/src/hash-algorithms-webcrypto.ts new file mode 100644 index 00000000..761de70d --- /dev/null +++ b/src/hash-algorithms-webcrypto.ts @@ -0,0 +1,124 @@ +import type { ErrorFirstCallback, HashAlgorithm } from "./types"; + +/** + * WebCrypto-based SHA-1 hash algorithm + * Uses the Web Crypto API which is available in browsers and modern Node.js + */ +export class WebCryptoSha1 implements HashAlgorithm { + getHash(xml: string): string; + getHash(xml: string, callback: ErrorFirstCallback): void; + getHash(xml: string, callback?: ErrorFirstCallback): string | void { + if (!callback) { + throw new Error( + "WebCrypto hash algorithms are async and require a callback. Use getHash(xml, callback).", + ); + } + + const encoder = new TextEncoder(); + const data = encoder.encode(xml); + crypto.subtle + .digest("SHA-1", data) + .then((hashBuffer) => { + const hash = this.arrayBufferToBase64(hashBuffer); + callback(null, hash); + }) + .catch((err) => { + callback(err); + }); + } + + getAlgorithmName = (): string => { + return "http://www.w3.org/2000/09/xmldsig#sha1"; + }; + + private arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } +} + +/** + * WebCrypto-based SHA-256 hash algorithm + * Uses the Web Crypto API which is available in browsers and modern Node.js + */ +export class WebCryptoSha256 implements HashAlgorithm { + getHash(xml: string): string; + getHash(xml: string, callback: ErrorFirstCallback): void; + getHash(xml: string, callback?: ErrorFirstCallback): string | void { + if (!callback) { + throw new Error( + "WebCrypto hash algorithms are async and require a callback. Use getHash(xml, callback).", + ); + } + + const encoder = new TextEncoder(); + const data = encoder.encode(xml); + crypto.subtle + .digest("SHA-256", data) + .then((hashBuffer) => { + const hash = this.arrayBufferToBase64(hashBuffer); + callback(null, hash); + }) + .catch((err) => { + callback(err); + }); + } + + getAlgorithmName = (): string => { + return "http://www.w3.org/2001/04/xmlenc#sha256"; + }; + + private arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } +} + +/** + * WebCrypto-based SHA-512 hash algorithm + * Uses the Web Crypto API which is available in browsers and modern Node.js + */ +export class WebCryptoSha512 implements HashAlgorithm { + getHash(xml: string): string; + getHash(xml: string, callback: ErrorFirstCallback): void; + getHash(xml: string, callback?: ErrorFirstCallback): string | void { + if (!callback) { + throw new Error( + "WebCrypto hash algorithms are async and require a callback. Use getHash(xml, callback).", + ); + } + + const encoder = new TextEncoder(); + const data = encoder.encode(xml); + crypto.subtle + .digest("SHA-512", data) + .then((hashBuffer) => { + const hash = this.arrayBufferToBase64(hashBuffer); + callback(null, hash); + }) + .catch((err) => { + callback(err); + }); + } + + getAlgorithmName = (): string => { + return "http://www.w3.org/2001/04/xmlenc#sha512"; + }; + + private arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + } +} diff --git a/src/index.ts b/src/index.ts index 3c82b7a8..897450dd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,13 @@ export { export { SignedXml } from "./signed-xml"; export * from "./types"; export * from "./utils"; + +// WebCrypto implementations - no Node.js dependencies +export { WebCryptoSha1, WebCryptoSha256, WebCryptoSha512 } from "./hash-algorithms-webcrypto"; +export { + WebCryptoRsaSha1, + WebCryptoRsaSha256, + WebCryptoRsaSha512, + WebCryptoHmacSha1, +} from "./signature-algorithms-webcrypto"; +export * from "./webcrypto-utils"; diff --git a/src/signature-algorithms-webcrypto.ts b/src/signature-algorithms-webcrypto.ts new file mode 100644 index 00000000..d0d427fd --- /dev/null +++ b/src/signature-algorithms-webcrypto.ts @@ -0,0 +1,475 @@ +import { + type BinaryLike, + type ErrorFirstCallback, + type KeyLike, + type SignatureAlgorithm, +} from "./types"; +import { + importRsaPrivateKey, + importRsaPublicKey, + importHmacKey, + arrayBufferToBase64, + base64ToArrayBuffer, +} from "./webcrypto-utils"; +import * as nodeCrypto from "crypto"; + +/** + * Check if a value is a CryptoKey (not a KeyObject) + * Guards against ReferenceError in environments without Web Crypto API + */ +function isCryptoKey(key: unknown): key is CryptoKey { + // CryptoKey has specific properties that KeyObject doesn't have + return ( + (typeof CryptoKey !== "undefined" && key instanceof CryptoKey) || + (typeof key === "object" && + key !== null && + "type" in key && + "algorithm" in key && + "extractable" in key && + "usages" in key && + !("export" in key)) // KeyObject has export, CryptoKey doesn't + ); +} + +/** + * Check if a value is a Node.js Buffer without directly referencing the Buffer global. + * This is browser-safe: it never calls Buffer.isBuffer() or accesses the Buffer global, + * preventing ReferenceError in environments where Buffer is not defined. + * In browsers, this will always return false; in Node.js, it correctly identifies Buffers. + */ +function isBuffer(value: unknown): value is Uint8Array { + // Safe: checks constructor name without accessing Buffer global + return value instanceof Uint8Array && value.constructor.name === "Buffer"; +} + +/** + * Check if a Uint8Array/Buffer contains valid UTF-8 text (like PEM format). + * This helps us distinguish between text-based keys (PEM) and binary keys (DER, raw bytes). + */ +function isPemText(data: Uint8Array): boolean { + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(data); + // Check if it looks like PEM format + return text.includes("-----BEGIN") && text.includes("-----END"); + } catch { + // Not valid UTF-8 text + return false; + } +} + +/** + * Normalize various key input types to a format suitable for Web Crypto API. + * Returns either a PEM string (for RSA keys) or ArrayBuffer (for raw binary keys like HMAC). + * Preserves binary data without UTF-8 mangling. + */ +function normalizeKey(key: unknown): string | ArrayBuffer { + if (typeof key === "string") { + return key; + } + + // Handle Uint8Array or Buffer + if (key instanceof Uint8Array || isBuffer(key)) { + const uint8Array = key as Uint8Array; + + // Check if this contains PEM text (common case: Buffer wrapping PEM string) + if (isPemText(uint8Array)) { + // Decode as UTF-8 text + return new TextDecoder("utf-8").decode(uint8Array); + } + + // Otherwise, preserve as binary (for DER keys, raw HMAC keys, etc.) + const buffer = new ArrayBuffer(uint8Array.byteLength); + const view = new Uint8Array(buffer); + view.set(uint8Array); + return buffer; + } + + // Handle ArrayBuffer - return as-is (assume binary) + if (key instanceof ArrayBuffer) { + return key; + } + + // Handle Node.js KeyObject + if ( + typeof key === "object" && + key !== null && + "type" in key && + "export" in key && + typeof (key as { export: unknown }).export === "function" && + !("algorithm" in key && "extractable" in key && "usages" in key) // Not a CryptoKey + ) { + const keyObject = key as nodeCrypto.KeyObject; + if (keyObject.type === "private") { + return keyObject.export({ type: "pkcs8", format: "pem" }) as string; + } else if (keyObject.type === "public") { + return keyObject.export({ type: "spki", format: "pem" }) as string; + } else if (keyObject.type === "secret") { + // For secret keys (HMAC), export as buffer and preserve binary data + const secretBuffer = keyObject.export(); + // Convert Node.js Buffer to ArrayBuffer properly + // Note: Buffer.buffer may be a pooled ArrayBuffer, so we need to copy the data + const arrayBuffer = new ArrayBuffer(secretBuffer.byteLength); + const view = new Uint8Array(arrayBuffer); + // Create a proper Uint8Array view of the Buffer to ensure compatibility + const bytes = new Uint8Array( + secretBuffer.buffer, + secretBuffer.byteOffset, + secretBuffer.byteLength, + ); + view.set(bytes); + return arrayBuffer; + } + } + throw new Error( + "Unsupported key type. Expected string (PEM), Buffer, Uint8Array, ArrayBuffer, KeyObject, or CryptoKey", + ); +} + +/** + * Convert various input types to ArrayBuffer for Web Crypto API. + * + * BROWSER SAFETY: This function never references the global Buffer object directly. + * It uses the browser-safe isBuffer() helper which only checks constructor.name, + * preventing ReferenceError in environments where Buffer is not defined. + */ +function toArrayBuffer(data: unknown): ArrayBuffer { + if (typeof data === "string") { + return new TextEncoder().encode(data).buffer; + } + if (data instanceof ArrayBuffer) { + return data; + } + // Browser-safe: isBuffer() never calls Buffer.isBuffer() or accesses Buffer global + if (data instanceof Uint8Array || isBuffer(data)) { + // Create a new ArrayBuffer from the Uint8Array/Buffer + const buffer = new ArrayBuffer((data as Uint8Array).byteLength); + const view = new Uint8Array(buffer); + view.set(data as Uint8Array); + return buffer; + } + throw new Error("Unsupported data type"); +} + +/** + * WebCrypto-based RSA-SHA1 signature algorithm + * Uses the Web Crypto API which is available in browsers and modern Node.js + */ +export class WebCryptoRsaSha1 implements SignatureAlgorithm { + getSignature(signedInfo: BinaryLike, privateKey: KeyLike): string; + getSignature( + signedInfo: BinaryLike, + privateKey: KeyLike, + callback: ErrorFirstCallback, + ): void; + getSignature( + signedInfo: BinaryLike, + privateKey: KeyLike, + callback?: ErrorFirstCallback, + ): string | void { + if (!callback) { + throw new Error("WebCrypto algorithms require a callback"); + } + + (async () => { + // If already a CryptoKey, use it directly + let key: CryptoKey; + if (isCryptoKey(privateKey)) { + key = privateKey; + } else { + // Normalize key (handles Buffer, KeyObject, etc.) + const normalizedKey = normalizeKey(privateKey); + key = await importRsaPrivateKey(normalizedKey, "SHA-1"); + } + + const data = toArrayBuffer(signedInfo); + const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, data); + return arrayBufferToBase64(signature); + })() + .then((result) => callback(null, result)) + .catch((err) => callback(err instanceof Error ? err : new Error("Unknown error"))); + } + + verifySignature(material: string, key: KeyLike, signatureValue: string): boolean; + verifySignature( + material: string, + key: KeyLike, + signatureValue: string, + callback: ErrorFirstCallback, + ): void; + verifySignature( + material: string, + key: KeyLike, + signatureValue: string, + callback?: ErrorFirstCallback, + ): boolean | void { + if (!callback) { + throw new Error("WebCrypto algorithms require a callback"); + } + + (async () => { + // If already a CryptoKey, use it directly + let publicKey: CryptoKey; + if (isCryptoKey(key)) { + publicKey = key; + } else { + // Normalize key (handles Buffer, KeyObject, etc.) + const normalizedKey = normalizeKey(key); + publicKey = await importRsaPublicKey(normalizedKey, "SHA-1"); + } + + const data = new TextEncoder().encode(material); + const signature = base64ToArrayBuffer(signatureValue); + return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", publicKey, signature, data); + })() + .then((result) => callback(null, result)) + .catch((err) => callback(err instanceof Error ? err : new Error("Unknown error"))); + } + + getAlgorithmName(): string { + return "http://www.w3.org/2000/09/xmldsig#rsa-sha1"; + } +} + +/** + * WebCrypto-based RSA-SHA256 signature algorithm + * Uses the Web Crypto API which is available in browsers and modern Node.js + */ +export class WebCryptoRsaSha256 implements SignatureAlgorithm { + getSignature(signedInfo: BinaryLike, privateKey: KeyLike): string; + getSignature( + signedInfo: BinaryLike, + privateKey: KeyLike, + callback: ErrorFirstCallback, + ): void; + getSignature( + signedInfo: BinaryLike, + privateKey: KeyLike, + callback?: ErrorFirstCallback, + ): string | void { + if (!callback) { + throw new Error("WebCrypto algorithms require a callback"); + } + + (async () => { + // If already a CryptoKey, use it directly + let key: CryptoKey; + if (isCryptoKey(privateKey)) { + key = privateKey; + } else { + // Normalize key (handles Buffer, KeyObject, etc.) + const normalizedKey = normalizeKey(privateKey); + key = await importRsaPrivateKey(normalizedKey, "SHA-256"); + } + + const data = toArrayBuffer(signedInfo); + const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, data); + return arrayBufferToBase64(signature); + })() + .then((result) => callback(null, result)) + .catch((err) => callback(err instanceof Error ? err : new Error("Unknown error"))); + } + + verifySignature(material: string, key: KeyLike, signatureValue: string): boolean; + verifySignature( + material: string, + key: KeyLike, + signatureValue: string, + callback: ErrorFirstCallback, + ): void; + verifySignature( + material: string, + key: KeyLike, + signatureValue: string, + callback?: ErrorFirstCallback, + ): boolean | void { + if (!callback) { + throw new Error("WebCrypto algorithms require a callback"); + } + + (async () => { + // If already a CryptoKey, use it directly + let publicKey: CryptoKey; + if (isCryptoKey(key)) { + publicKey = key; + } else { + // Normalize key (handles Buffer, KeyObject, etc.) + const normalizedKey = normalizeKey(key); + publicKey = await importRsaPublicKey(normalizedKey, "SHA-256"); + } + + const data = new TextEncoder().encode(material); + const signature = base64ToArrayBuffer(signatureValue); + return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", publicKey, signature, data); + })() + .then((result) => callback(null, result)) + .catch((err) => callback(err instanceof Error ? err : new Error("Unknown error"))); + } + + getAlgorithmName(): string { + return "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + } +} + +/** + * WebCrypto-based RSA-SHA512 signature algorithm + * Uses the Web Crypto API which is available in browsers and modern Node.js + */ +export class WebCryptoRsaSha512 implements SignatureAlgorithm { + getSignature(signedInfo: BinaryLike, privateKey: KeyLike): string; + getSignature( + signedInfo: BinaryLike, + privateKey: KeyLike, + callback: ErrorFirstCallback, + ): void; + getSignature( + signedInfo: BinaryLike, + privateKey: KeyLike, + callback?: ErrorFirstCallback, + ): string | void { + if (!callback) { + throw new Error("WebCrypto algorithms require a callback"); + } + + (async () => { + // If already a CryptoKey, use it directly + let key: CryptoKey; + if (isCryptoKey(privateKey)) { + key = privateKey; + } else { + // Normalize key (handles Buffer, KeyObject, etc.) + const normalizedKey = normalizeKey(privateKey); + key = await importRsaPrivateKey(normalizedKey, "SHA-512"); + } + + const data = toArrayBuffer(signedInfo); + const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, data); + return arrayBufferToBase64(signature); + })() + .then((result) => callback(null, result)) + .catch((err) => callback(err instanceof Error ? err : new Error("Unknown error"))); + } + + verifySignature(material: string, key: KeyLike, signatureValue: string): boolean; + verifySignature( + material: string, + key: KeyLike, + signatureValue: string, + callback: ErrorFirstCallback, + ): void; + verifySignature( + material: string, + key: KeyLike, + signatureValue: string, + callback?: ErrorFirstCallback, + ): boolean | void { + if (!callback) { + throw new Error("WebCrypto algorithms require a callback"); + } + + (async () => { + // If already a CryptoKey, use it directly + let publicKey: CryptoKey; + if (isCryptoKey(key)) { + publicKey = key; + } else { + // Normalize key (handles Buffer, KeyObject, etc.) + const normalizedKey = normalizeKey(key); + publicKey = await importRsaPublicKey(normalizedKey, "SHA-512"); + } + + const data = toArrayBuffer(material); + const signature = base64ToArrayBuffer(signatureValue); + return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", publicKey, signature, data); + })() + .then((result) => callback(null, result)) + .catch((err) => callback(err instanceof Error ? err : new Error("Unknown error"))); + } + + getAlgorithmName(): string { + return "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"; + } +} + +/** + * WebCrypto-based HMAC-SHA1 signature algorithm + * Uses the Web Crypto API which is available in browsers and modern Node.js + */ +export class WebCryptoHmacSha1 implements SignatureAlgorithm { + getSignature(signedInfo: BinaryLike, privateKey: KeyLike): string; + getSignature( + signedInfo: BinaryLike, + privateKey: KeyLike, + callback: ErrorFirstCallback, + ): void; + getSignature( + signedInfo: BinaryLike, + privateKey: KeyLike, + callback?: ErrorFirstCallback, + ): string | void { + if (!callback) { + throw new Error("WebCrypto algorithms require a callback"); + } + + (async () => { + // If already a CryptoKey, use it directly + let key: CryptoKey; + if (isCryptoKey(privateKey)) { + key = privateKey; + } else { + // Normalize key (handles Buffer, KeyObject, etc.) + // HMAC keys can be binary (ArrayBuffer) or string + const normalizedKey = normalizeKey(privateKey); + key = await importHmacKey(normalizedKey, "SHA-1"); + } + + const data = toArrayBuffer(signedInfo); + const signature = await crypto.subtle.sign("HMAC", key, data); + return arrayBufferToBase64(signature); + })() + .then((result) => callback(null, result)) + .catch((err) => callback(err instanceof Error ? err : new Error("Unknown error"))); + } + + verifySignature(material: string, key: KeyLike, signatureValue: string): boolean; + verifySignature( + material: string, + key: KeyLike, + signatureValue: string, + callback: ErrorFirstCallback, + ): void; + verifySignature( + material: string, + key: KeyLike, + signatureValue: string, + callback?: ErrorFirstCallback, + ): boolean | void { + if (!callback) { + throw new Error("WebCrypto algorithms require a callback"); + } + + (async () => { + // If already a CryptoKey, use it directly + let hmacKey: CryptoKey; + if (isCryptoKey(key)) { + hmacKey = key; + } else { + // Normalize key (handles Buffer, KeyObject, etc.) + // HMAC keys can be binary (ArrayBuffer) or string + const normalizedKey = normalizeKey(key); + hmacKey = await importHmacKey(normalizedKey, "SHA-1"); + } + + const data = new TextEncoder().encode(material); + const signature = base64ToArrayBuffer(signatureValue); + + // Use crypto.subtle.verify for constant-time comparison (prevents timing attacks) + return await crypto.subtle.verify("HMAC", hmacKey, signature, data); + })() + .then((result) => callback(null, result)) + .catch((err) => callback(err instanceof Error ? err : new Error("Unknown error"))); + } + + getAlgorithmName(): string { + return "http://www.w3.org/2000/09/xmldsig#hmac-sha1"; + } +} diff --git a/src/signature-algorithms.ts b/src/signature-algorithms.ts index ab1e919f..f532c858 100644 --- a/src/signature-algorithms.ts +++ b/src/signature-algorithms.ts @@ -1,22 +1,27 @@ import * as crypto from "crypto"; -import { type SignatureAlgorithm, createOptionalCallbackFunction } from "./types"; +import { + type SignatureAlgorithm, + type BinaryLike, + type KeyLike, + createOptionalCallbackFunction, +} from "./types"; export class RsaSha1 implements SignatureAlgorithm { getSignature = createOptionalCallbackFunction( - (signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike): string => { + (signedInfo: BinaryLike, privateKey: KeyLike): string => { const signer = crypto.createSign("RSA-SHA1"); - signer.update(signedInfo); - const res = signer.sign(privateKey, "base64"); + signer.update(signedInfo as crypto.BinaryLike); + const res = signer.sign(privateKey as crypto.KeyLike, "base64"); return res; }, ); verifySignature = createOptionalCallbackFunction( - (material: string, key: crypto.KeyLike, signatureValue: string): boolean => { + (material: string, key: KeyLike, signatureValue: string): boolean => { const verifier = crypto.createVerify("RSA-SHA1"); verifier.update(material); - const res = verifier.verify(key, signatureValue, "base64"); + const res = verifier.verify(key as crypto.KeyLike, signatureValue, "base64"); return res; }, @@ -29,20 +34,20 @@ export class RsaSha1 implements SignatureAlgorithm { export class RsaSha256 implements SignatureAlgorithm { getSignature = createOptionalCallbackFunction( - (signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike): string => { + (signedInfo: BinaryLike, privateKey: KeyLike): string => { const signer = crypto.createSign("RSA-SHA256"); - signer.update(signedInfo); - const res = signer.sign(privateKey, "base64"); + signer.update(signedInfo as crypto.BinaryLike); + const res = signer.sign(privateKey as crypto.KeyLike, "base64"); return res; }, ); verifySignature = createOptionalCallbackFunction( - (material: string, key: crypto.KeyLike, signatureValue: string): boolean => { + (material: string, key: KeyLike, signatureValue: string): boolean => { const verifier = crypto.createVerify("RSA-SHA256"); verifier.update(material); - const res = verifier.verify(key, signatureValue, "base64"); + const res = verifier.verify(key as crypto.KeyLike, signatureValue, "base64"); return res; }, @@ -55,20 +60,20 @@ export class RsaSha256 implements SignatureAlgorithm { export class RsaSha512 implements SignatureAlgorithm { getSignature = createOptionalCallbackFunction( - (signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike): string => { + (signedInfo: BinaryLike, privateKey: KeyLike): string => { const signer = crypto.createSign("RSA-SHA512"); - signer.update(signedInfo); - const res = signer.sign(privateKey, "base64"); + signer.update(signedInfo as crypto.BinaryLike); + const res = signer.sign(privateKey as crypto.KeyLike, "base64"); return res; }, ); verifySignature = createOptionalCallbackFunction( - (material: string, key: crypto.KeyLike, signatureValue: string): boolean => { + (material: string, key: KeyLike, signatureValue: string): boolean => { const verifier = crypto.createVerify("RSA-SHA512"); verifier.update(material); - const res = verifier.verify(key, signatureValue, "base64"); + const res = verifier.verify(key as crypto.KeyLike, signatureValue, "base64"); return res; }, @@ -81,9 +86,9 @@ export class RsaSha512 implements SignatureAlgorithm { export class HmacSha1 implements SignatureAlgorithm { getSignature = createOptionalCallbackFunction( - (signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike): string => { - const signer = crypto.createHmac("SHA1", privateKey); - signer.update(signedInfo); + (signedInfo: BinaryLike, privateKey: KeyLike): string => { + const signer = crypto.createHmac("SHA1", privateKey as crypto.BinaryLike | crypto.KeyObject); + signer.update(signedInfo as crypto.BinaryLike); const res = signer.digest("base64"); return res; @@ -91,8 +96,8 @@ export class HmacSha1 implements SignatureAlgorithm { ); verifySignature = createOptionalCallbackFunction( - (material: string, key: crypto.KeyLike, signatureValue: string): boolean => { - const verifier = crypto.createHmac("SHA1", key); + (material: string, key: KeyLike, signatureValue: string): boolean => { + const verifier = crypto.createHmac("SHA1", key as crypto.BinaryLike | crypto.KeyObject); verifier.update(material); const res = verifier.digest("base64"); diff --git a/src/signed-xml.ts b/src/signed-xml.ts index 05dae417..6014f363 100644 --- a/src/signed-xml.ts +++ b/src/signed-xml.ts @@ -8,6 +8,7 @@ import type { GetKeyInfoContentArgs, HashAlgorithm, HashAlgorithmType, + KeyLike, Reference, SignatureAlgorithm, SignatureAlgorithmType, @@ -16,7 +17,6 @@ import type { import * as isDomNode from "@xmldom/is-dom-node"; import * as xmldom from "@xmldom/xmldom"; -import * as crypto from "crypto"; import { deprecate } from "util"; import * as xpath from "xpath"; import * as c14n from "./c14n-canonicalization"; @@ -32,8 +32,8 @@ export class SignedXml { /** * A {@link Buffer} or pem encoded {@link String} containing your private key */ - privateKey?: crypto.KeyLike; - publicCert?: crypto.KeyLike; + privateKey?: KeyLike; + publicCert?: KeyLike; /** * One of the supported signature algorithms. * @see {@link SignatureAlgorithmType} @@ -312,6 +312,36 @@ export class SignedXml { this.loadReference(reference); } + // Use callback-based validation if callback provided + if (callback) { + let completed = 0; + let hasError = false; + + this.references.forEach((ref) => { + this.validateReference(ref, doc, (err, isValid) => { + if (hasError) return; // Already failed + + if (err || !isValid) { + hasError = true; + this.signedReferences = []; + this.references.forEach((ref) => { + ref.signedReference = undefined; + }); + callback(err || new Error("Could not validate all references"), false); + return; + } + + completed++; + if (completed === this.references.length) { + // Continue with signature verification + this.verifySignatureValue(unverifiedSignedInfoCanon, callback); + } + }); + }); + return; + } + + // Synchronous validation (for Node.js crypto algorithms) /* eslint-disable-next-line deprecation/deprecation */ if (!this.getReferences().every((ref) => this.validateReference(ref, doc))) { /* Trustworthiness can only be determined if SignedInfo's (which holds References' DigestValue(s) @@ -328,11 +358,6 @@ export class SignedXml { }); // TODO: add this breaking change here later on for even more security: `this.references = [];` - if (callback) { - callback(new Error("Could not validate all references"), false); - return; - } - // We return false because some references validated, but not all // We should actually be throwing an error here, but that would be a breaking change // See https://www.w3.org/TR/xmldsig-core/#sec-CoreValidation @@ -340,7 +365,6 @@ export class SignedXml { } // (Stage B authentication step, show that the `signedInfoCanon` is signed) - // First find the key & signature algorithm, these should match // Stage B: Take the signature algorithm and key and verify the `SignatureValue` against the canonicalized `SignedInfo` const signer = this.findSignatureAlgorithm(this.signatureAlgorithm); @@ -352,11 +376,7 @@ export class SignedXml { // Check the signature verification to know whether to reset signature value or not. const sigRes = signer.verifySignature(unverifiedSignedInfoCanon, key, this.signatureValue); if (sigRes === true) { - if (callback) { - callback(null, true); - } else { - return true; - } + return true; } else { // Ideally, we would start by verifying the `signedInfoCanon` first, // but that may cause some breaking changes, so we'll handle that in v7.x. @@ -367,17 +387,43 @@ export class SignedXml { }); // TODO: add this breaking change here later on for even more security: `this.references = [];` - if (callback) { + throw new Error(`invalid signature: the signature value ${this.signatureValue} is incorrect`); + } + } + + private verifySignatureValue( + unverifiedSignedInfoCanon: string, + callback: (error: Error | null, isValid?: boolean) => void, + ): void { + const signer = this.findSignatureAlgorithm(this.signatureAlgorithm); + const key = this.getCertFromKeyInfo(this.keyInfo) || this.publicCert || this.privateKey; + if (key == null) { + throw new Error("KeyInfo or publicCert or privateKey is required to validate signature"); + } + + // Use callback for signature verification (required for WebCrypto) + signer.verifySignature(unverifiedSignedInfoCanon, key, this.signatureValue, (err, isValid) => { + if (err) { + this.signedReferences = []; + this.references.forEach((ref) => { + ref.signedReference = undefined; + }); + callback(err); + return; + } + + if (isValid) { + callback(null, true); + } else { + this.signedReferences = []; + this.references.forEach((ref) => { + ref.signedReference = undefined; + }); callback( new Error(`invalid signature: the signature value ${this.signatureValue} is incorrect`), ); - return; // return early - } else { - throw new Error( - `invalid signature: the signature value ${this.signatureValue} is incorrect`, - ); } - } + }); } private getCanonSignedInfoXml(doc: Document) { @@ -517,7 +563,17 @@ export class SignedXml { throw new Error("No references passed validation"); } - private validateReference(ref: Reference, doc: Document) { + private validateReference(ref: Reference, doc: Document): boolean; + private validateReference( + ref: Reference, + doc: Document, + callback: (error: Error | null, isValid?: boolean) => void, + ): void; + private validateReference( + ref: Reference, + doc: Document, + callback?: (error: Error | null, isValid?: boolean) => void, + ): boolean | void { const uri = ref.uri?.[0] === "#" ? ref.uri.substring(1) : ref.uri; let elem: xpath.SelectSingleReturnType = null; @@ -525,7 +581,12 @@ export class SignedXml { elem = xpath.select1("//*", doc); } else if (uri?.indexOf("'") !== -1) { // xpath injection - throw new Error("Cannot validate a uri with quotes inside it"); + const err = new Error("Cannot validate a uri with quotes inside it"); + if (callback) { + callback(err, false); + return; + } + throw err; } else { let num_elements_for_id = 0; for (const attr of this.idAttributes) { @@ -535,11 +596,16 @@ export class SignedXml { num_elements_for_id += tmp_elem.length; if (num_elements_for_id > 1) { - throw new Error( + const err = new Error( "Cannot validate a document which contains multiple elements with the " + "same value for the ID / Id / Id attributes, in order to prevent " + "signature wrapping attack.", ); + if (callback) { + callback(err, false); + return; + } + throw err; } elem = tmp_elem[0]; @@ -562,11 +628,42 @@ export class SignedXml { `invalid signature: the signature references an element with uri ${ref.uri} but could not find such element in the xml`, ); ref.validationError = validationError; + if (callback) { + callback(validationError, false); + return; + } return false; } const canonXml = this.getCanonReferenceXml(doc, ref, elem); const hash = this.findHashAlgorithm(ref.digestAlgorithm); + + // If callback provided, use async hash computation (for WebCrypto) + if (callback) { + hash.getHash(canonXml, (err, computedDigest) => { + if (err) { + ref.validationError = err; + callback(err, false); + return; + } + + if (!utils.validateDigestValue(computedDigest!, ref.digestValue)) { + const validationError = new Error( + `invalid signature: for uri ${ref.uri} calculated digest is ${computedDigest} but the xml to validate supplies digest ${ref.digestValue}`, + ); + ref.validationError = validationError; + callback(validationError, false); + return; + } + + this.signedReferences.push(canonXml); + ref.signedReference = canonXml; + callback(null, true); + }); + return; + } + + // Synchronous path (for Node.js crypto) const digest = hash.getHash(canonXml); if (!utils.validateDigestValue(digest, ref.digestValue)) { @@ -936,12 +1033,12 @@ export class SignedXml { location.action }, must be any of the following values: ${validActions.join(", ")}`, ); - if (!callback) { - throw err; - } else { + if (callback) { callback(err); - return; + } else { + throw err; } + return; } // automatic insertion of `:` @@ -961,90 +1058,150 @@ export class SignedXml { // add the xml namespace attribute signatureAttrs.push(`${xmlNsAttr}="http://www.w3.org/2000/09/xmldsig#"`); - let signatureXml = `<${currentPrefix}Signature ${signatureAttrs.join(" ")}>`; + if (callback) { + this.createSignedInfo(doc, prefix, (err, signedInfoXml) => { + if (err) { + callback(err); + return; + } - signatureXml += this.createSignedInfo(doc, prefix); - signatureXml += this.getKeyInfo(prefix); - signatureXml += ``; + // Build the signature XML + let signatureXml = `<${currentPrefix}Signature ${signatureAttrs.join(" ")}>`; + signatureXml += signedInfoXml; + signatureXml += this.getKeyInfo(prefix); + signatureXml += ``; + + this.originalXmlWithIds = doc.toString(); + + let existingPrefixesString = ""; + Object.keys(existingPrefixes).forEach(function (key) { + existingPrefixesString += `xmlns:${key}="${existingPrefixes[key]}" `; + }); + + // A trick to remove the namespaces that already exist in the xml + // This only works if the prefix and namespace match with those in the xml + const dummySignatureWrapper = `${signatureXml}`; + const nodeXml = new xmldom.DOMParser().parseFromString(dummySignatureWrapper); + + // Because we are using a dummy wrapper hack described above, we know there will be a `firstChild` + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const signatureDoc = nodeXml.documentElement.firstChild!; + + const referenceNode = xpath.select1(location.reference!, doc); + + if (!isDomNode.isNodeLike(referenceNode)) { + callback( + new Error( + `the following xpath cannot be used because it was not found: ${location.reference}`, + ), + ); + return; + } - this.originalXmlWithIds = doc.toString(); + if (location.action === "append") { + referenceNode.appendChild(signatureDoc); + } else if (location.action === "prepend") { + referenceNode.insertBefore(signatureDoc, referenceNode.firstChild); + } else if (location.action === "before") { + if (referenceNode.parentNode == null) { + callback( + new Error( + "`location.reference` refers to the root node (by default), so we can't insert `before`", + ), + ); + return; + } + referenceNode.parentNode.insertBefore(signatureDoc, referenceNode); + } else if (location.action === "after") { + if (referenceNode.parentNode == null) { + callback( + new Error( + "`location.reference` refers to the root node (by default), so we can't insert `after`", + ), + ); + return; + } + referenceNode.parentNode.insertBefore(signatureDoc, referenceNode.nextSibling); + } - let existingPrefixesString = ""; - Object.keys(existingPrefixes).forEach(function (key) { - existingPrefixesString += `xmlns:${key}="${existingPrefixes[key]}" `; - }); + this.signatureNode = signatureDoc; + const signedInfoNodes = utils.findChildren(this.signatureNode, "SignedInfo"); + if (signedInfoNodes.length === 0) { + callback(new Error("could not find SignedInfo element in the message")); + return; + } + const signedInfoNode = signedInfoNodes[0]; - // A trick to remove the namespaces that already exist in the xml - // This only works if the prefix and namespace match with those in the xml - const dummySignatureWrapper = `${signatureXml}`; - const nodeXml = new xmldom.DOMParser().parseFromString(dummySignatureWrapper); + this.calculateSignatureValue(doc, (err, signature) => { + if (err) { + callback(err); + } else { + this.signatureValue = signature || ""; + signatureDoc.insertBefore(this.createSignature(prefix), signedInfoNode.nextSibling); + this.signatureXml = signatureDoc.toString(); + this.signedXml = doc.toString(); + callback(null, this); + } + }); + }); + } else { + // Build the signature XML + let signatureXml = `<${currentPrefix}Signature ${signatureAttrs.join(" ")}>`; + signatureXml += this.createSignedInfo(doc, prefix); + signatureXml += this.getKeyInfo(prefix); + signatureXml += ``; - // Because we are using a dummy wrapper hack described above, we know there will be a `firstChild` - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const signatureDoc = nodeXml.documentElement.firstChild!; + this.originalXmlWithIds = doc.toString(); - const referenceNode = xpath.select1(location.reference, doc); + let existingPrefixesString = ""; + Object.keys(existingPrefixes).forEach(function (key) { + existingPrefixesString += `xmlns:${key}="${existingPrefixes[key]}" `; + }); - if (!isDomNode.isNodeLike(referenceNode)) { - const err2 = new Error( - `the following xpath cannot be used because it was not found: ${location.reference}`, - ); - if (!callback) { - throw err2; - } else { - callback(err2); - return; - } - } + // A trick to remove the namespaces that already exist in the xml + // This only works if the prefix and namespace match with those in the xml + const dummySignatureWrapper = `${signatureXml}`; + const nodeXml = new xmldom.DOMParser().parseFromString(dummySignatureWrapper); + + // Because we are using a dummy wrapper hack described above, we know there will be a `firstChild` + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const signatureDoc = nodeXml.documentElement.firstChild!; + + const referenceNode = xpath.select1(location.reference, doc); - if (location.action === "append") { - referenceNode.appendChild(signatureDoc); - } else if (location.action === "prepend") { - referenceNode.insertBefore(signatureDoc, referenceNode.firstChild); - } else if (location.action === "before") { - if (referenceNode.parentNode == null) { + if (!isDomNode.isNodeLike(referenceNode)) { throw new Error( - "`location.reference` refers to the root node (by default), so we can't insert `before`", + `the following xpath cannot be used because it was not found: ${location.reference}`, ); } - referenceNode.parentNode.insertBefore(signatureDoc, referenceNode); - } else if (location.action === "after") { - if (referenceNode.parentNode == null) { - throw new Error( - "`location.reference` refers to the root node (by default), so we can't insert `after`", - ); + + if (location.action === "append") { + referenceNode.appendChild(signatureDoc); + } else if (location.action === "prepend") { + referenceNode.insertBefore(signatureDoc, referenceNode.firstChild); + } else if (location.action === "before") { + if (referenceNode.parentNode == null) { + throw new Error( + "`location.reference` refers to the root node (by default), so we can't insert `before`", + ); + } + referenceNode.parentNode.insertBefore(signatureDoc, referenceNode); + } else if (location.action === "after") { + if (referenceNode.parentNode == null) { + throw new Error( + "`location.reference` refers to the root node (by default), so we can't insert `after`", + ); + } + referenceNode.parentNode.insertBefore(signatureDoc, referenceNode.nextSibling); } - referenceNode.parentNode.insertBefore(signatureDoc, referenceNode.nextSibling); - } - this.signatureNode = signatureDoc; - const signedInfoNodes = utils.findChildren(this.signatureNode, "SignedInfo"); - if (signedInfoNodes.length === 0) { - const err3 = new Error("could not find SignedInfo element in the message"); - if (!callback) { - throw err3; - } else { - callback(err3); - return; + this.signatureNode = signatureDoc; + const signedInfoNodes = utils.findChildren(this.signatureNode, "SignedInfo"); + if (signedInfoNodes.length === 0) { + throw new Error("could not find SignedInfo element in the message"); } - } - const signedInfoNode = signedInfoNodes[0]; + const signedInfoNode = signedInfoNodes[0]; - if (typeof callback === "function") { - // Asynchronous flow - this.calculateSignatureValue(doc, (err, signature) => { - if (err) { - callback(err); - } else { - this.signatureValue = signature || ""; - signatureDoc.insertBefore(this.createSignature(prefix), signedInfoNode.nextSibling); - this.signatureXml = signatureDoc.toString(); - this.signedXml = doc.toString(); - callback(null, this); - } - }); - } else { - // Synchronous flow this.calculateSignatureValue(doc); signatureDoc.insertBefore(this.createSignature(prefix), signedInfoNode.nextSibling); this.signatureXml = signatureDoc.toString(); @@ -1074,20 +1231,37 @@ export class SignedXml { * Generate the Reference nodes (as part of the signature process) * */ - private createReferences(doc, prefix) { - let res = ""; - + private createReferences(doc, prefix): string; + private createReferences( + doc, + prefix, + callback: (error: Error | null, xml?: string) => void, + ): void; + private createReferences( + doc, + prefix, + callback?: (error: Error | null, xml?: string) => void, + ): string | void { prefix = prefix || ""; prefix = prefix ? `${prefix}:` : prefix; /* eslint-disable-next-line deprecation/deprecation */ - for (const ref of this.getReferences()) { + const refs = this.getReferences(); + let res = ""; + const referenceXmls: string[] = []; + + for (const ref of refs) { const nodes = xpath.selectWithResolver(ref.xpath ?? "", doc, this.namespaceResolver); if (!utils.isArrayHasLength(nodes)) { - throw new Error( + const err = new Error( `the following xpath cannot be signed because it was not found: ${ref.xpath}`, ); + if (callback) { + callback(err); + return; + } + throw err; } for (const node of nodes) { @@ -1112,19 +1286,44 @@ export class SignedXml { res += " />"; } } + res += ``; const canonXml = this.getCanonReferenceXml(doc, ref, node); - const digestAlgorithm = this.findHashAlgorithm(ref.digestAlgorithm); - res += - `` + - `<${prefix}DigestMethod Algorithm="${digestAlgorithm.getAlgorithmName()}" />` + - `<${prefix}DigestValue>${digestAlgorithm.getHash(canonXml)}` + - ``; + + if (!callback) { + const digestValue = digestAlgorithm.getHash(canonXml); + res += `<${prefix}DigestMethod Algorithm="${digestAlgorithm.getAlgorithmName()}" />`; + res += `<${prefix}DigestValue>${digestValue}`; + res += ``; + } else { + // Capture the current reference XML prefix before the async callback + const refXmlPrefix = res; + res = ""; // Reset for next iteration + + digestAlgorithm.getHash(canonXml, (err, digest) => { + if (err) { + callback(err); + return; + } + + let refXml = refXmlPrefix; + refXml += `<${prefix}DigestMethod Algorithm="${digestAlgorithm.getAlgorithmName()}" />`; + refXml += `<${prefix}DigestValue>${digest}`; + refXml += ``; + referenceXmls.push(refXml); + + if (referenceXmls.length === nodes.length * refs.length) { + callback(null, referenceXmls.join("")); + } + }); + } } } - return res; + if (!callback) { + return res; + } } getCanonXml( @@ -1205,11 +1404,26 @@ export class SignedXml { * Create the SignedInfo element * */ - private createSignedInfo(doc, prefix) { + private createSignedInfo(doc, prefix): string; + private createSignedInfo( + doc, + prefix, + callback: (error: Error | null, xml?: string) => void, + ): void; + private createSignedInfo( + doc, + prefix, + callback?: (error: Error | null, xml?: string) => void, + ): string | void { if (typeof this.canonicalizationAlgorithm !== "string") { - throw new Error( + const err = new Error( "Missing canonicalizationAlgorithm when trying to create signed info for XML", ); + if (callback) { + callback(err); + return; + } + throw err; } const transform = this.findCanonicalizationAlgorithm(this.canonicalizationAlgorithm); const algo = this.findSignatureAlgorithm(this.signatureAlgorithm); @@ -1231,9 +1445,21 @@ export class SignedXml { } res += `<${currentPrefix}SignatureMethod Algorithm="${algo.getAlgorithmName()}" />`; - res += this.createReferences(doc, prefix); - res += ``; - return res; + // Async mode + if (callback) { + this.createReferences(doc, prefix, (err, referencesXml) => { + if (err) { + callback(err); + return; + } + callback(null, res + referencesXml + ``); + }); + return; + } + + // Sync mode + const referencesXml = this.createReferences(doc, prefix); + return res + referencesXml + ``; } /** diff --git a/src/types.ts b/src/types.ts index f102c4c7..27732791 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,19 @@ import * as crypto from "crypto"; export type ErrorFirstCallback = (err: Error | null, result?: T) => void; +/** + * Binary data types that can be used for signing and verification. + * Compatible with both Node.js crypto and Web Crypto API. + */ +export type BinaryLike = crypto.BinaryLike | ArrayBuffer; + +/** + * Key types that can be used with xml-crypto. + * Includes Node.js crypto.KeyLike (for Node.js crypto) and Web Crypto API CryptoKey. + * Also supports Uint8Array for raw key material. + */ +export type KeyLike = crypto.KeyLike | CryptoKey | Uint8Array; + export type CanonicalizationAlgorithmType = | "http://www.w3.org/TR/2001/REC-xml-c14n-20010315" | "http://www.w3.org/TR/2001/REC-xml-c14n-20010315#WithComments" @@ -39,7 +52,7 @@ export type SignatureAlgorithmType = * @param prefix an optional namespace alias to be used for the generated XML */ export interface GetKeyInfoContentArgs { - publicCert?: crypto.KeyLike; + publicCert?: KeyLike; prefix?: string | null; } @@ -49,8 +62,8 @@ export interface GetKeyInfoContentArgs { export interface SignedXmlOptions { idMode?: "wssecurity"; idAttribute?: string; - privateKey?: crypto.KeyLike; - publicCert?: crypto.KeyLike; + privateKey?: KeyLike; + publicCert?: KeyLike; signatureAlgorithm?: SignatureAlgorithmType; canonicalizationAlgorithm?: CanonicalizationAlgorithmType; inclusiveNamespacesPrefixList?: string | string[]; @@ -152,6 +165,7 @@ export interface HashAlgorithm { getAlgorithmName(): HashAlgorithmType; getHash(xml: string): string; + getHash(xml: string, callback: ErrorFirstCallback): void; } /** Extend this to create a new SignatureAlgorithm */ @@ -159,23 +173,23 @@ export interface SignatureAlgorithm { /** * Sign the given string using the given key */ - getSignature(signedInfo: crypto.BinaryLike, privateKey: crypto.KeyLike): string; + getSignature(signedInfo: BinaryLike, privateKey: KeyLike): string; getSignature( - signedInfo: crypto.BinaryLike, - privateKey: crypto.KeyLike, - callback?: ErrorFirstCallback, + signedInfo: BinaryLike, + privateKey: KeyLike, + callback: ErrorFirstCallback, ): void; /** * Verify the given signature of the given string using key * * @param key a public cert, public key, or private key can be passed here */ - verifySignature(material: string, key: crypto.KeyLike, signatureValue: string): boolean; + verifySignature(material: string, key: KeyLike, signatureValue: string): boolean; verifySignature( material: string, - key: crypto.KeyLike, + key: KeyLike, signatureValue: string, - callback?: ErrorFirstCallback, + callback: ErrorFirstCallback, ): void; getAlgorithmName(): SignatureAlgorithmType; diff --git a/src/webcrypto-utils.ts b/src/webcrypto-utils.ts new file mode 100644 index 00000000..5482e0d8 --- /dev/null +++ b/src/webcrypto-utils.ts @@ -0,0 +1,170 @@ +/** + * Utility functions for working with Web Crypto API + */ + +/** + * Convert a PEM string to an ArrayBuffer + * @param pem PEM-encoded key (with or without headers) + * @returns ArrayBuffer containing the binary key data + */ +export function pemToArrayBuffer(pem: string): ArrayBuffer { + // Remove PEM headers and whitespace + const pemContent = pem + .replace(/-----BEGIN [A-Z ]+-----/, "") + .replace(/-----END [A-Z ]+-----/, "") + .replace(/\s/g, ""); + + // Decode base64 to binary string + const binaryString = atob(pemContent); + + // Convert binary string to ArrayBuffer + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + return bytes.buffer; +} + +/** + * Convert an ArrayBuffer to base64 string + * @param buffer ArrayBuffer to convert + * @returns Base64-encoded string + */ +export function arrayBufferToBase64(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +/** + * Convert a base64 string to ArrayBuffer + * @param base64 Base64-encoded string + * @returns ArrayBuffer + */ +export function base64ToArrayBuffer(base64: string): ArrayBuffer { + const binaryString = atob(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes.buffer; +} + +/** + * Import a PEM-encoded RSA private key for signing + * @param pem PEM-encoded private key + * @param hashAlgorithm Hash algorithm name (e.g., "SHA-1", "SHA-256", "SHA-512") + * @returns CryptoKey for signing + */ +export async function importRsaPrivateKey( + pem: string | ArrayBuffer, + hashAlgorithm: string, +): Promise { + const keyData = typeof pem === "string" ? pemToArrayBuffer(pem) : pem; + + return await crypto.subtle.importKey( + "pkcs8", + keyData, + { + name: "RSASSA-PKCS1-v1_5", + hash: { name: hashAlgorithm }, + }, + false, + ["sign"], + ); +} + +/** + * Import a PEM-encoded RSA public key for verification + * @param pem PEM-encoded public key or certificate + * @param hashAlgorithm Hash algorithm name (e.g., "SHA-1", "SHA-256", "SHA-512") + * @returns CryptoKey for verification + */ +export async function importRsaPublicKey( + pem: string | ArrayBuffer, + hashAlgorithm: string, +): Promise { + let keyData: ArrayBuffer; + + if (typeof pem === "string") { + // Check if this is a certificate + if (pem.includes("BEGIN CERTIFICATE")) { + // For certificates, we need to extract the public key + // This is a basic implementation - for production use, consider using a proper ASN.1 parser + // Web Crypto API doesn't support X.509 certificates directly + // For now, we'll try to parse it as SPKI and provide a helpful error + keyData = pemToArrayBuffer(pem); + + // Try to extract the public key from the certificate + // This is a simplified approach and may not work for all certificates + try { + return await crypto.subtle.importKey( + "spki", + keyData, + { + name: "RSASSA-PKCS1-v1_5", + hash: { name: hashAlgorithm }, + }, + false, + ["verify"], + ); + } catch (error) { + throw new Error( + "X.509 certificates are not directly supported by Web Crypto API. " + + "Please extract the public key from the certificate and provide it in SPKI format, " + + "or use Node.js crypto algorithms instead. " + + `Original error: ${error}`, + ); + } + } + keyData = pemToArrayBuffer(pem); + } else { + keyData = pem; + } + + // Try importing as SPKI (SubjectPublicKeyInfo) format + try { + return await crypto.subtle.importKey( + "spki", + keyData, + { + name: "RSASSA-PKCS1-v1_5", + hash: { name: hashAlgorithm }, + }, + false, + ["verify"], + ); + } catch (error) { + throw new Error( + `Failed to import RSA public key. Please ensure the key is in SPKI format. ${error}`, + ); + } +} + +/** + * Import an HMAC key + * @param key Key material (string or ArrayBuffer) + * @param hashAlgorithm Hash algorithm name (e.g., "SHA-1", "SHA-256", "SHA-512") + * @returns CryptoKey for HMAC operations + */ +export async function importHmacKey( + key: string | ArrayBuffer, + hashAlgorithm: string, +): Promise { + const keyData = typeof key === "string" ? new TextEncoder().encode(key) : key; + + return await crypto.subtle.importKey( + "raw", + keyData, + { + name: "HMAC", + hash: { name: hashAlgorithm }, + }, + false, + ["sign", "verify"], + ); +} diff --git a/test/signature-unit-tests.spec.ts b/test/signature-unit-tests.spec.ts index baa382db..506718fa 100644 --- a/test/signature-unit-tests.spec.ts +++ b/test/signature-unit-tests.spec.ts @@ -6,7 +6,74 @@ import * as crypto from "crypto"; import { expect } from "chai"; import * as isDomNode from "@xmldom/is-dom-node"; +const signatureAlgorithms = [ + "http://www.w3.org/2000/09/xmldsig#rsa-sha1", + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256", + "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512", +]; + describe("Signature unit tests", function () { + describe("sign and verify", function () { + signatureAlgorithms.forEach((signatureAlgorithm) => { + function signWith(signatureAlgorithm: string): string { + const xml = ''; + const sig = new SignedXml(); + sig.privateKey = fs.readFileSync("./test/static/client.pem"); + + sig.addReference({ + xpath: "//*[local-name(.)='x']", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + sig.signatureAlgorithm = signatureAlgorithm; + sig.computeSignature(xml); + return sig.getSignedXml(); + } + + function loadSignature(xml: string): SignedXml { + const doc = new xmldom.DOMParser().parseFromString(xml); + const node = xpath.select1( + "//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']", + doc, + ); + isDomNode.assertIsNodeLike(node); + const sig = new SignedXml(); + sig.publicCert = fs.readFileSync("./test/static/client_public.pem"); + sig.loadSignature(node); + return sig; + } + + it(`should verify signed xml with ${signatureAlgorithm}`, function () { + const xml = signWith(signatureAlgorithm); + const sig = loadSignature(xml); + const res = sig.checkSignature(xml); + expect( + res, + `expected all signatures with ${signatureAlgorithm} to be valid, but some reported invalid`, + ).to.be.true; + }); + + it(`should fail verification of signed xml with ${signatureAlgorithm} after manipulation`, function () { + const xml = signWith(signatureAlgorithm); + const doc = new xmldom.DOMParser().parseFromString(xml); + const node = xpath.select1("//*[local-name(.)='x']", doc); + isDomNode.assertIsElementNode(node); + const targetElement = node as Element; + targetElement.setAttribute("attr", "manipulatedValue"); + const manipulatedXml = new xmldom.XMLSerializer().serializeToString(doc); + + const sig = loadSignature(manipulatedXml); + const res = sig.checkSignature(manipulatedXml); + expect( + res, + `expected all signatures with ${signatureAlgorithm} to be invalid, but some reported valid`, + ).to.be.false; + }); + }); + }); + describe("verify adds ID", function () { function nodeExists(doc, xpathArg) { if (!doc && !xpathArg) { diff --git a/test/webcrypto-tests.spec.ts b/test/webcrypto-tests.spec.ts new file mode 100644 index 00000000..5e866533 --- /dev/null +++ b/test/webcrypto-tests.spec.ts @@ -0,0 +1,885 @@ +import { SignedXml } from "../src/signed-xml"; +import { WebCryptoSha1, WebCryptoSha256, WebCryptoSha512 } from "../src/hash-algorithms-webcrypto"; +import { + WebCryptoRsaSha1, + WebCryptoRsaSha256, + WebCryptoRsaSha512, + WebCryptoHmacSha1, +} from "../src/signature-algorithms-webcrypto"; +import { importRsaPrivateKey, importRsaPublicKey } from "../src/webcrypto-utils"; +import { expect } from "chai"; +import { readFileSync } from "fs"; +import * as xmldom from "@xmldom/xmldom"; + +describe("WebCrypto Hash Algorithms", function () { + it("WebCryptoSha1 should compute hash correctly", function (done) { + const hash = new WebCryptoSha1(); + const xml = "data"; + hash.getHash(xml, (err, digest) => { + if (err) return done(err); + + // Verify it returns a base64 string + expect(digest).to.be.a("string"); + if (digest) { + expect(digest.length).to.be.greaterThan(0); + expect(() => Buffer.from(digest, "base64")).to.not.throw(); + } + + // Verify algorithm name + expect(hash.getAlgorithmName()).to.equal("http://www.w3.org/2000/09/xmldsig#sha1"); + done(); + }); + }); + + it("WebCryptoSha256 should compute hash correctly", function (done) { + const hash = new WebCryptoSha256(); + const xml = "data"; + hash.getHash(xml, (err, digest) => { + if (err) return done(err); + + expect(digest).to.be.a("string"); + if (digest) { + expect(digest.length).to.be.greaterThan(0); + expect(() => Buffer.from(digest, "base64")).to.not.throw(); + } + expect(hash.getAlgorithmName()).to.equal("http://www.w3.org/2001/04/xmlenc#sha256"); + done(); + }); + }); + + it("WebCryptoSha512 should compute hash correctly", function (done) { + const hash = new WebCryptoSha512(); + const xml = "data"; + hash.getHash(xml, (err, digest) => { + if (err) return done(err); + + expect(digest).to.be.a("string"); + if (digest) { + expect(digest.length).to.be.greaterThan(0); + expect(() => Buffer.from(digest, "base64")).to.not.throw(); + } + expect(hash.getAlgorithmName()).to.equal("http://www.w3.org/2001/04/xmlenc#sha512"); + done(); + }); + }); + + it("should produce consistent hashes for same input", function (done) { + const hash = new WebCryptoSha256(); + const xml = "consistent data"; + hash.getHash(xml, (err1, digest1) => { + if (err1) return done(err1); + + hash.getHash(xml, (err2, digest2) => { + if (err2) return done(err2); + + expect(digest1).to.equal(digest2); + done(); + }); + }); + }); + + it("should produce different hashes for different inputs", function (done) { + const hash = new WebCryptoSha256(); + const xml1 = "data1"; + const xml2 = "data2"; + hash.getHash(xml1, (err1, digest1) => { + if (err1) return done(err1); + + hash.getHash(xml2, (err2, digest2) => { + if (err2) return done(err2); + + expect(digest1).to.not.equal(digest2); + done(); + }); + }); + }); +}); + +describe("WebCrypto RSA Signature Algorithms", function () { + let privateKey: string; + let publicKey: string; + + before(function () { + privateKey = readFileSync("./test/static/client.pem", "utf8"); + publicKey = readFileSync("./test/static/client_public.pem", "utf8"); + }); + + describe("WebCryptoRsaSha256", function () { + it("should sign and verify data correctly", function (done) { + const algo = new WebCryptoRsaSha256(); + const data = "test data to sign"; + + algo.getSignature(data, privateKey, async (err, signature) => { + if (err) return done(err); + + expect(signature).to.be.a("string"); + if (signature) { + expect(signature.length).to.be.greaterThan(0); + } + + // Extract public key to SPKI format for WebCrypto + const crypto = await import("crypto"); + const publicKeyObj = crypto.createPublicKey(publicKey); + const spkiPem = publicKeyObj.export({ type: "spki", format: "pem" }) as string; + + algo.verifySignature(data, spkiPem, signature!, (verifyErr, isValid) => { + if (verifyErr) return done(verifyErr); + + expect(isValid).to.be.true; + done(); + }); + }); + }); + + it("should fail verification with wrong data", function (done) { + const algo = new WebCryptoRsaSha256(); + const data = "test data to sign"; + + algo.getSignature(data, privateKey, async (err, signature) => { + if (err) return done(err); + + const crypto = await import("crypto"); + const publicKeyObj = crypto.createPublicKey(publicKey); + const spkiPem = publicKeyObj.export({ type: "spki", format: "pem" }) as string; + + algo.verifySignature("wrong data", spkiPem, signature!, (verifyErr, isValid) => { + if (verifyErr) return done(verifyErr); + + expect(isValid).to.be.false; + done(); + }); + }); + }); + + it("should have correct algorithm name", function () { + const algo = new WebCryptoRsaSha256(); + expect(algo.getAlgorithmName()).to.equal("http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"); + }); + }); + + describe("WebCryptoRsaSha1", function () { + it("should sign and verify data correctly", function (done) { + const algo = new WebCryptoRsaSha1(); + const data = "test data to sign"; + + algo.getSignature(data, privateKey, async (err, signature) => { + if (err) return done(err); + + expect(signature).to.be.a("string"); + + const crypto = await import("crypto"); + const publicKeyObj = crypto.createPublicKey(publicKey); + const spkiPem = publicKeyObj.export({ type: "spki", format: "pem" }) as string; + + algo.verifySignature(data, spkiPem, signature!, (verifyErr, isValid) => { + if (verifyErr) return done(verifyErr); + + expect(isValid).to.be.true; + done(); + }); + }); + }); + + it("should have correct algorithm name", function () { + const algo = new WebCryptoRsaSha1(); + expect(algo.getAlgorithmName()).to.equal("http://www.w3.org/2000/09/xmldsig#rsa-sha1"); + }); + }); + + describe("WebCryptoRsaSha512", function () { + it("should sign and verify data correctly", function (done) { + const algo = new WebCryptoRsaSha512(); + const data = "test data to sign"; + + algo.getSignature(data, privateKey, async (err, signature) => { + if (err) return done(err); + + expect(signature).to.be.a("string"); + + const crypto = await import("crypto"); + const publicKeyObj = crypto.createPublicKey(publicKey); + const spkiPem = publicKeyObj.export({ type: "spki", format: "pem" }) as string; + + algo.verifySignature(data, spkiPem, signature!, (verifyErr, isValid) => { + if (verifyErr) return done(verifyErr); + + expect(isValid).to.be.true; + done(); + }); + }); + }); + + it("should have correct algorithm name", function () { + const algo = new WebCryptoRsaSha512(); + expect(algo.getAlgorithmName()).to.equal("http://www.w3.org/2001/04/xmldsig-more#rsa-sha512"); + }); + }); +}); + +describe("WebCrypto HMAC Signature Algorithm", function () { + describe("WebCryptoHmacSha1", function () { + it("should sign and verify data correctly", function (done) { + const algo = new WebCryptoHmacSha1(); + const data = "test data to sign"; + const key = "my-secret-key"; + + algo.getSignature(data, key, (err, signature) => { + if (err) return done(err); + + expect(signature).to.be.a("string"); + if (signature) { + expect(signature.length).to.be.greaterThan(0); + } + + algo.verifySignature(data, key, signature!, (verifyErr, isValid) => { + if (verifyErr) return done(verifyErr); + + expect(isValid).to.be.true; + done(); + }); + }); + }); + + it("should fail verification with wrong key", function (done) { + const algo = new WebCryptoHmacSha1(); + const data = "test data to sign"; + const key = "my-secret-key"; + + algo.getSignature(data, key, (err, signature) => { + if (err) return done(err); + + algo.verifySignature(data, "wrong-key", signature!, (verifyErr, isValid) => { + if (verifyErr) return done(verifyErr); + + expect(isValid).to.be.false; + done(); + }); + }); + }); + + it("should have correct algorithm name", function () { + const algo = new WebCryptoHmacSha1(); + expect(algo.getAlgorithmName()).to.equal("http://www.w3.org/2000/09/xmldsig#hmac-sha1"); + }); + }); +}); + +describe("WebCrypto XML Signing and Verification", function () { + let privateKey: string; + let publicKey: string; + + before(function () { + privateKey = readFileSync("./test/static/client.pem", "utf8"); + publicKey = readFileSync("./test/static/client_public.pem", "utf8"); + }); + + it("should sign and verify XML with WebCrypto RSA-SHA256", function (done) { + const xml = "Harry Potter"; + + // Sign + const sig = new SignedXml(); + sig.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + sig.privateKey = privateKey; + + sig.addReference({ + xpath: "//*[local-name(.)='book']", + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + // Register WebCrypto algorithms + sig.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + sig.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = + WebCryptoRsaSha256; + + sig.computeSignature(xml, (err) => { + if (err) { + return done(err); + } + + const signedXml = sig.getSignedXml(); + + expect(signedXml).to.include(""); + + // Verify + const verifier = new SignedXml(); + + // Convert certificate to SPKI format for WebCrypto + import("crypto") + .then((crypto) => { + const publicKeyObj = crypto.createPublicKey(publicKey); + const spkiPem = publicKeyObj.export({ type: "spki", format: "pem" }) as string; + + verifier.publicCert = spkiPem; + + verifier.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + verifier.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = + WebCryptoRsaSha256; + + // Load signature from the signed XML + const doc = new xmldom.DOMParser().parseFromString(signedXml); + const signature = verifier.findSignatures(doc)[0]; + verifier.loadSignature(signature); + + verifier.checkSignature(signedXml, (error, isValid) => { + if (error) { + return done(error); + } + expect(isValid).to.be.true; + done(); + }); + }) + .catch(done); + }); + }); + + it("should sign and verify XML with WebCrypto RSA-SHA1", function (done) { + const xml = "test content"; + + // Sign + const sig = new SignedXml(); + sig.signatureAlgorithm = "http://www.w3.org/2000/09/xmldsig#rsa-sha1"; + sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + sig.privateKey = privateKey; + + sig.addReference({ + xpath: "//*[local-name(.)='data']", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + sig.HashAlgorithms["http://www.w3.org/2000/09/xmldsig#sha1"] = WebCryptoSha1; + sig.SignatureAlgorithms["http://www.w3.org/2000/09/xmldsig#rsa-sha1"] = WebCryptoRsaSha1; + + sig.computeSignature(xml, (err) => { + if (err) { + return done(err); + } + + const signedXml = sig.getSignedXml(); + + // Verify + const verifier = new SignedXml(); + + import("crypto") + .then((crypto) => { + const publicKeyObj = crypto.createPublicKey(publicKey); + const spkiPem = publicKeyObj.export({ type: "spki", format: "pem" }) as string; + + verifier.publicCert = spkiPem; + + verifier.HashAlgorithms["http://www.w3.org/2000/09/xmldsig#sha1"] = WebCryptoSha1; + verifier.SignatureAlgorithms["http://www.w3.org/2000/09/xmldsig#rsa-sha1"] = + WebCryptoRsaSha1; + + // Load signature from the signed XML + const doc = new xmldom.DOMParser().parseFromString(signedXml); + const signature = verifier.findSignatures(doc)[0]; + verifier.loadSignature(signature); + + verifier.checkSignature(signedXml, (error, isValid) => { + if (error) { + return done(error); + } + expect(isValid).to.be.true; + done(); + }); + }) + .catch(done); + }); + }); + + it("should detect invalid signatures", function (done) { + const xml = "test content"; + + // Sign + const sig = new SignedXml(); + sig.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + sig.privateKey = privateKey; + + sig.addReference({ + xpath: "//*[local-name(.)='data']", + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + sig.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + sig.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = + WebCryptoRsaSha256; + + sig.computeSignature(xml, (err) => { + if (err) { + return done(err); + } + + let signedXml = sig.getSignedXml(); + + // Tamper with the signed data + signedXml = signedXml.replace("test content", "tampered content"); + + // Verify should fail + const verifier = new SignedXml(); + + import("crypto") + .then((crypto) => { + const publicKeyObj = crypto.createPublicKey(publicKey); + const spkiPem = publicKeyObj.export({ type: "spki", format: "pem" }) as string; + + verifier.publicCert = spkiPem; + + verifier.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + verifier.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = + WebCryptoRsaSha256; + + // Load signature from the signed XML + const doc = new xmldom.DOMParser().parseFromString(signedXml); + const signature = verifier.findSignatures(doc)[0]; + verifier.loadSignature(signature); + + verifier.checkSignature(signedXml, (error, isValid) => { + expect(error).to.exist; + expect(error?.message).to.include("invalid signature"); + expect(isValid).to.be.false; + done(); + }); + }) + .catch(done); + }); + }); + + it("should throw error when using async algorithms with sync methods", function () { + const xml = "test"; + + const sig = new SignedXml(); + sig.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + sig.privateKey = privateKey; + + sig.addReference({ + xpath: "//*[local-name(.)='data']", + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + // Register WebCrypto algorithms + sig.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + sig.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = + WebCryptoRsaSha256; + + // Should throw when using sync method with async algorithm + // Hash computation happens first, so we get hash algorithm error + expect(() => sig.computeSignature(xml)).to.throw( + "WebCrypto hash algorithms are async and require a callback", + ); + }); + + it("should throw error when verifying with async algorithms using sync methods", function (done) { + const xml = "test"; + + // First, create a signed XML using callbacks + const signer = new SignedXml(); + signer.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + signer.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + signer.privateKey = privateKey; + + signer.addReference({ + xpath: "//*[local-name(.)='data']", + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + signer.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + signer.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = + WebCryptoRsaSha256; + + signer.computeSignature(xml, (err) => { + if (err) { + return done(err); + } + + const signedXml = signer.getSignedXml(); + + // Now try to verify using sync method - should throw + const verifier = new SignedXml(); + + import("crypto") + .then((crypto) => { + const publicKeyObj = crypto.createPublicKey(publicKey); + const spkiPem = publicKeyObj.export({ type: "spki", format: "pem" }) as string; + + verifier.publicCert = spkiPem; + verifier.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + verifier.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = + WebCryptoRsaSha256; + + // Load signature first + const doc = new xmldom.DOMParser().parseFromString(signedXml); + const signature = verifier.findSignatures(doc)[0]; + verifier.loadSignature(signature); + + // Should throw when using sync method with async algorithm for verification + // Hash validation happens first, so we get hash algorithm error + expect(() => verifier.checkSignature(signedXml)).to.throw( + "WebCrypto hash algorithms are async and require a callback", + ); + done(); + }) + .catch(done); + }); + }); + + it("should work with multiple references", function (done) { + const xml = "FirstSecond"; + + const sig = new SignedXml(); + sig.signatureAlgorithm = "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"; + sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + sig.privateKey = privateKey; + + // Add multiple references + sig.addReference({ + xpath: "//*[local-name(.)='item'][@id='1']", + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + sig.addReference({ + xpath: "//*[local-name(.)='item'][@id='2']", + digestAlgorithm: "http://www.w3.org/2001/04/xmlenc#sha256", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + sig.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + sig.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = + WebCryptoRsaSha256; + + sig.computeSignature(xml, (err) => { + if (err) { + return done(err); + } + + const signedXml = sig.getSignedXml(); + + // Verify + const verifier = new SignedXml(); + + import("crypto") + .then((crypto) => { + const publicKeyObj = crypto.createPublicKey(publicKey); + const spkiPem = publicKeyObj.export({ type: "spki", format: "pem" }) as string; + + verifier.publicCert = spkiPem; + + verifier.HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"] = WebCryptoSha256; + verifier.SignatureAlgorithms["http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"] = + WebCryptoRsaSha256; + + // Load signature from the signed XML + const doc = new xmldom.DOMParser().parseFromString(signedXml); + const signature = verifier.findSignatures(doc)[0]; + verifier.loadSignature(signature); + + verifier.checkSignature(signedXml, (error, isValid) => { + if (error) { + return done(error); + } + expect(isValid).to.be.true; + done(); + }); + }) + .catch(done); + }); + }); +}); + +describe("WebCrypto HMAC XML Signing", function () { + it("should sign and verify XML with HMAC-SHA1", function (done) { + const xml = "HMAC test"; + const hmacKey = "my-secret-hmac-key"; + + // Sign + const sig = new SignedXml(); + sig.signatureAlgorithm = "http://www.w3.org/2000/09/xmldsig#hmac-sha1"; + sig.canonicalizationAlgorithm = "http://www.w3.org/2001/10/xml-exc-c14n#"; + sig.privateKey = hmacKey; + + sig.addReference({ + xpath: "//*[local-name(.)='data']", + digestAlgorithm: "http://www.w3.org/2000/09/xmldsig#sha1", + transforms: ["http://www.w3.org/2001/10/xml-exc-c14n#"], + }); + + sig.HashAlgorithms["http://www.w3.org/2000/09/xmldsig#sha1"] = WebCryptoSha1; + sig.SignatureAlgorithms["http://www.w3.org/2000/09/xmldsig#hmac-sha1"] = WebCryptoHmacSha1; + + sig.computeSignature(xml, (err) => { + if (err) { + return done(err); + } + + const signedXml = sig.getSignedXml(); + + // Verify + const verifier = new SignedXml(); + verifier.publicCert = hmacKey; + + verifier.HashAlgorithms["http://www.w3.org/2000/09/xmldsig#sha1"] = WebCryptoSha1; + verifier.SignatureAlgorithms["http://www.w3.org/2000/09/xmldsig#hmac-sha1"] = + WebCryptoHmacSha1; + + // Load signature from the signed XML + const doc = new xmldom.DOMParser().parseFromString(signedXml); + const signature = verifier.findSignatures(doc)[0]; + verifier.loadSignature(signature); + + verifier.checkSignature(signedXml, (error, isValid) => { + if (error) { + return done(error); + } + expect(isValid).to.be.true; + done(); + }); + }); + }); +}); + +describe("WebCrypto Callback-Style API", function () { + let privateKey: string; + let publicKey: string; + + before(function () { + privateKey = readFileSync("./test/static/client.pem", "utf8"); + publicKey = readFileSync("./test/static/client_public.pem", "utf8"); + }); + + it("should support callback-style getSignature for RSA-SHA1", function (done) { + const signer = new WebCryptoRsaSha1(); + const data = "test data"; + + signer.getSignature(data, privateKey, (err, signature) => { + if (err) { + return done(err); + } + expect(signature).to.be.a("string"); + if (signature) { + expect(signature.length).to.be.greaterThan(0); + } + done(); + }); + }); + + it("should support callback-style verifySignature for RSA-SHA256", function (done) { + const signer = new WebCryptoRsaSha256(); + const data = "test data"; + + // First sign + signer.getSignature(data, privateKey, async (err, signature) => { + if (err || !signature) { + return done(err || new Error("No signature")); + } + + // Then verify with callback + const crypto = await import("crypto"); + const publicKeyObj = crypto.createPublicKey(publicKey); + const spkiPem = publicKeyObj.export({ type: "spki", format: "pem" }) as string; + + signer.verifySignature(data, spkiPem, signature, (verifyErr, isValid) => { + if (verifyErr) { + return done(verifyErr); + } + expect(isValid).to.be.true; + done(); + }); + }); + }); + + it("should support callback-style for HMAC-SHA1", function (done) { + const signer = new WebCryptoHmacSha1(); + const data = "test data"; + const key = "my-hmac-key"; + + signer.getSignature(data, key, (err, signature) => { + if (err || !signature) { + return done(err || new Error("No signature")); + } + + signer.verifySignature(data, key, signature, (verifyErr, isValid) => { + if (verifyErr) { + return done(verifyErr); + } + expect(isValid).to.be.true; + done(); + }); + }); + }); + + it("should handle errors in callback-style API", function (done) { + const signer = new WebCryptoRsaSha1(); + const data = "test data"; + const invalidKey = "not a valid key"; + + signer.getSignature(data, invalidKey, (err) => { + expect(err).to.exist; + expect(err).to.be.instanceOf(Error); + done(); + }); + }); +}); + +describe("WebCrypto Key Type Support", function () { + let privateKeyString: string; + let privateKeyBuffer: Buffer; + let publicKeyString: string; + let publicKeyBuffer: Buffer; + + before(function () { + privateKeyString = readFileSync("./test/static/client.pem", "utf8"); + privateKeyBuffer = readFileSync("./test/static/client.pem"); + publicKeyString = readFileSync("./test/static/client_public.pem", "utf8"); + publicKeyBuffer = readFileSync("./test/static/client_public.pem"); + }); + + it("should accept Buffer as private key for signing", function (done) { + const signer = new WebCryptoRsaSha256(); + const data = "test data with buffer key"; + + signer.getSignature(data, privateKeyBuffer, (err, signature) => { + if (err) return done(err); + + expect(signature).to.be.a("string"); + if (signature) { + expect(signature.length).to.be.greaterThan(0); + } + done(); + }); + }); + + it("should accept Buffer as public key for verification", function (done) { + const signer = new WebCryptoRsaSha256(); + const data = "test data with buffer key"; + + // Sign with string key + signer.getSignature(data, privateKeyString, async (err, signature) => { + if (err || !signature) return done(err); + + // Verify with buffer key + const crypto = await import("crypto"); + const publicKeyObj = crypto.createPublicKey(publicKeyBuffer); + const spkiPem = publicKeyObj.export({ type: "spki", format: "pem" }) as string; + + signer.verifySignature(data, Buffer.from(spkiPem), signature, (verifyErr, isValid) => { + if (verifyErr) return done(verifyErr); + + expect(isValid).to.be.true; + done(); + }); + }); + }); + + it("should accept KeyObject as private key for signing", function (done) { + import("crypto") + .then((crypto) => { + const signer = new WebCryptoRsaSha256(); + const data = "test data with KeyObject"; + + const privateKeyObj = crypto.createPrivateKey(privateKeyString); + signer.getSignature(data, privateKeyObj, (err, signature) => { + if (err) return done(err); + + expect(signature).to.be.a("string"); + if (signature) { + expect(signature.length).to.be.greaterThan(0); + } + done(); + }); + }) + .catch(done); + }); + + it("should accept KeyObject as public key for verification", function (done) { + import("crypto") + .then((crypto) => { + const signer = new WebCryptoRsaSha256(); + const data = "test data with KeyObject"; + + // Sign with string key + signer.getSignature(data, privateKeyString, (err, signature) => { + if (err || !signature) return done(err); + + // Verify with KeyObject + const publicKeyObj = crypto.createPublicKey(publicKeyString); + + signer.verifySignature(data, publicKeyObj, signature, (verifyErr, isValid) => { + if (verifyErr) return done(verifyErr); + + expect(isValid).to.be.true; + done(); + }); + }); + }) + .catch(done); + }); + + it("should accept secret KeyObject for HMAC signing", function (done) { + import("crypto") + .then((crypto) => { + const signer = new WebCryptoHmacSha1(); + const data = "test data with secret KeyObject"; + + // Create a secret KeyObject + const secretKey = crypto.createSecretKey( + Uint8Array.from(Buffer.from("my-hmac-secret-key")), + ); + signer.getSignature(data, secretKey, (err, signature) => { + if (err || !signature) return done(err); + + expect(signature).to.be.a("string"); + expect(signature.length).to.be.greaterThan(0); + + // Verify with same secret KeyObject + signer.verifySignature(data, secretKey, signature, (verifyErr, isValid) => { + if (verifyErr) return done(verifyErr); + + expect(isValid).to.be.true; + done(); + }); + }); + }) + .catch(done); + }); + + it("should accept Uint8Array as key", function (done) { + const signer = new WebCryptoRsaSha256(); + const data = "test data with Uint8Array"; + + const privateKeyUint8 = new Uint8Array(privateKeyBuffer); + signer.getSignature(data, privateKeyUint8, (err, signature) => { + if (err) return done(err); + + expect(signature).to.be.a("string"); + if (signature) { + expect(signature.length).to.be.greaterThan(0); + } + done(); + }); + }); + + it("should work with Buffer keys in callback-style API", function (done) { + const signer = new WebCryptoRsaSha256(); + const data = "test data with buffer in callback"; + + signer.getSignature(data, privateKeyBuffer, (err, signature) => { + if (err) { + return done(err); + } + expect(signature).to.be.a("string"); + if (signature) { + expect(signature.length).to.be.greaterThan(0); + } + done(); + }); + }); +});