From 34eae24749093379aab51354bd9485d72ac23b16 Mon Sep 17 00:00:00 2001 From: Chris Barth Date: Wed, 9 Sep 2026 17:01:59 -0500 Subject: [PATCH 1/4] feat!: shrink the public export surface to what it means to publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `index.ts` did `export * from "./utils"`, so every helper written for `signed-xml.ts` to use became public API at the same time. That is how `findChilds` stayed published with no callers and how each new helper joined the surface without anyone choosing to publish it. The export list is explicit now, and it names the four helpers a consumer has a real use for: `derToPem`, `pemToDer` and `normalizePem` for key-format conversion, and `findAncestorNs`, which is already part of the custom canonicalization contract in practice — three projects on GitHub import it alongside `C14nCanonicalization` to canonicalize a document subset by hand. See the survey on #551. `validateDigestValue` goes internal despite the "check before dropping" note. Nobody imports it, and publishing the constant-time comparison is what invites someone to reimplement it; it belongs behind the API that already uses it. `BASE64_REGEX` and `PEM_FORMAT_REGEX` lose their `export` keyword outright, since no sibling module uses them. The rest stay exported from `utils.ts` for their siblings but are no longer re-exported to consumers — a sibling and a consumer reaching a helper through the same `export` keyword is what caused this, and `index.ts` is the only place that can now widen the surface. `index.ts` only governs the barrel, though. With no `exports` map every file under `lib/` was reachable by path, and consumers do reach: one published package imports `xml-crypto/lib/hash-algorithms.js` for a class we never exported. Declare an `exports` map naming the entry point and `package.json` and nothing else, so a deep import fails loudly instead of quietly depending on the build layout. The bundled algorithm classes stay reachable through the `HashAlgorithms`, `SignatureAlgorithms` and `CanonicalizationAlgorithms` registries that name them. `test/public-api-tests.spec.ts` pins both the runtime export names and the declared subpaths, so widening either is a deliberate edit rather than a side effect of adding a helper or a file. The README documented an `xpath` export that 6.x does not have — the verification example destructured it and would have thrown. Point the examples at the `xpath` package instead, whose `select()` takes the expression first, and replace that section with the actual export list. BREAKING CHANGE: `findAttr`, `findChildren`, `findChilds`, `isDescendantOf`, `isArrayHasLength`, `encodeSpecialCharactersInAttribute`, `encodeSpecialCharactersInText`, `validateDigestValue`, `BASE64_REGEX`, `EXTRACT_X509_CERTS` and `PEM_FORMAT_REGEX` are no longer exported, and subpaths into `lib/` no longer resolve. See the Upgrading section of the README for replacements. Closes #551 Co-Authored-By: Claude Opus 5 --- README.md | 77 +++++++++++++++++++++++++++++++---- package.json | 10 ++++- src/index.ts | 29 ++++++++++++- src/utils.ts | 4 +- test/public-api-tests.spec.ts | 47 +++++++++++++++++++++ 5 files changed, 154 insertions(+), 13 deletions(-) create mode 100644 test/public-api-tests.spec.ts diff --git a/README.md b/README.md index 891e91c5..9da1fe57 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,47 @@ ## Upgrading +### Upgrading to 7.0 + +The package used to re-export everything in its internal `utils` module, so helpers written +for `signed-xml.ts` became public API by accident. The export list is explicit now and the +following are no longer exported: + +| Removed | Instead | +| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `findAttr`, `findChildren`, `findChilds`, `isDescendantOf` | use a DOM API, or [xpath](https://github.com/goto100/xpath) | +| `encodeSpecialCharactersInAttribute`, `encodeSpecialCharactersInText` | these implement [c14n special-character normalization](https://www.w3.org/TR/xml-c14n#ProcessingModel); a serializer such as [xmldom](https://github.com/xmldom/xmldom) escapes for you | +| `isArrayHasLength` | `Array.isArray(x) && x.length > 0` | +| `validateDigestValue` | `crypto.timingSafeEqual(Buffer.from(a, "base64"), Buffer.from(b, "base64"))` — it throws on a length mismatch, which counts as unequal | +| `BASE64_REGEX`, `EXTRACT_X509_CERTS`, `PEM_FORMAT_REGEX` | no replacement; these were internal parsing details | + +`derToPem`, `pemToDer`, `normalizePem` and `findAncestorNs` are still exported. See +[exports](#exports) for the whole surface. + +`xpath` was never exported by 6.x despite being documented here; the README examples now +require the [xpath](https://github.com/goto100/xpath) package directly, and its `select()` +takes the expression first. + +Reaching past the entry point no longer resolves either. `package.json` declares an `exports` +map, so a deep import such as `require("xml-crypto/lib/hash-algorithms.js")` fails with +`ERR_PACKAGE_PATH_NOT_EXPORTED` instead of quietly depending on the build layout. The bundled +algorithm classes are still reachable, through the registries that name them: + +```js +const { SignedXml } = require("xml-crypto"); + +// before +const { Sha256 } = require("xml-crypto/lib/hash-algorithms.js"); +// after +const Sha256 = new SignedXml().HashAlgorithms["http://www.w3.org/2001/04/xmlenc#sha256"]; +``` + +`SignatureAlgorithms` and `CanonicalizationAlgorithms` work the same way. If you need +something from `lib/` that no registry names, please open an issue rather than reaching for the +path — that is the conversation the `exports` map exists to start. + +### Upgrading to 6.0 + The `.getReferences()` AND the `.references` APIs are deprecated. Please do not attempt to access them. The content in them should be treated as unsigned. @@ -150,16 +191,16 @@ new SignedXml({ }); ``` -You can use any dom parser you want in your code (or none, depending on your usage). This sample uses [xmldom](https://github.com/xmldom/xmldom), so you should install it first: +You can use any dom parser you want in your code (or none, depending on your usage). This sample uses [xmldom](https://github.com/xmldom/xmldom) to parse and [xpath](https://github.com/goto100/xpath) to select, so install those first: ```shell -npm install @xmldom/xmldom +npm install @xmldom/xmldom xpath ``` Example: ```javascript -var select = require("xml-crypto").xpath, +var select = require("xpath").select, dom = require("@xmldom/xmldom").DOMParser, SignedXml = require("xml-crypto").SignedXml, fs = require("fs"); @@ -173,8 +214,8 @@ var doc = new dom().parseFromString(xml); // good: see below var signature = select( - doc, "//*[local-name(.)='Signature' and namespace-uri(.)='http://www.w3.org/2000/09/xmldsig#']", + doc, )[0]; var sig = new SignedXml({ publicCert: fs.readFileSync("client_public.pem") }); sig.loadSignature(signature); @@ -238,10 +279,30 @@ You might find it difficult to guess such transforms, but there are typical tran ## API -### xpath - -See [xpath.js](https://github.com/yaronn/xpath.js) for usage. Note that this is actually using -[another library](https://github.com/goto100/xpath) as the underlying implementation. +### Exports + +The package exports these values: + +| Export | Purpose | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | +| `SignedXml` | signing and verification — see below | +| `C14nCanonicalization`, `C14nCanonicalizationWithComments` | the [canonicalization algorithms](#canonicalization-and-transformation-algorithms) | +| `ExclusiveCanonicalization`, `ExclusiveCanonicalizationWithComments` | as above, exclusive | +| `findAncestorNs` | ancestor namespaces for a document subset, for [custom canonicalization](#customizing-algorithms) | +| `derToPem`, `pemToDer`, `normalizePem` | [key-format conversion](#x509--key-formats) | +| `createOptionalCallbackFunction` | adds a callback form to a synchronous algorithm method | + +Plus the types `CanonicalizationAlgorithmType`, `CanonicalizationOrTransformAlgorithmType`, +`CanonicalizationOrTransformationAlgorithm`, +`CanonicalizationOrTransformationAlgorithmProcessOptions`, `ComputeSignatureOptions`, +`ComputeSignatureOptionsLocation`, `ErrorFirstCallback`, `GetKeyInfoContentArgs`, +`HashAlgorithm`, `HashAlgorithmType`, `NamespacePrefix`, `ObjectAttributes`, `Reference`, +`RenderedNamespace`, `SignatureAlgorithm`, `SignatureAlgorithmType`, `SignedXmlOptions` and +`TransformAlgorithm`. + +Anything not on this list is internal. `package.json` declares an `exports` map naming only +this entry point, so a path into `lib/` no longer resolves and the list above is the whole +surface rather than a convention. ### SignedXml diff --git a/package.json b/package.json index b0e7fdf8..53000e35 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,15 @@ "LoneRifle ", "Chris Barth " ], - "main": "./lib", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "default": "./lib/index.js" + }, + "./package.json": "./package.json" + }, + "main": "./lib/index.js", + "types": "./lib/index.d.ts", "files": [ "lib", "LICENSE", diff --git a/src/index.ts b/src/index.ts index 3c82b7a8..bc6abb31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,5 +4,30 @@ export { ExclusiveCanonicalizationWithComments, } from "./exclusive-canonicalization"; export { SignedXml } from "./signed-xml"; -export * from "./types"; -export * from "./utils"; + +export type { + CanonicalizationAlgorithmType, + CanonicalizationOrTransformAlgorithmType, + CanonicalizationOrTransformationAlgorithm, + CanonicalizationOrTransformationAlgorithmProcessOptions, + ComputeSignatureOptions, + ComputeSignatureOptionsLocation, + ErrorFirstCallback, + GetKeyInfoContentArgs, + HashAlgorithm, + HashAlgorithmType, + NamespacePrefix, + ObjectAttributes, + Reference, + RenderedNamespace, + SignatureAlgorithm, + SignatureAlgorithmType, + SignedXmlOptions, + TransformAlgorithm, +} from "./types"; +export { createOptionalCallbackFunction } from "./types"; + +// Key-format conversion, and the ancestor-namespace lookup an implementer of a custom +// canonicalization algorithm needs. Everything else in `./utils` is internal to this +// package: it is exported from that module for its siblings, not for consumers. +export { derToPem, findAncestorNs, normalizePem, pemToDer } from "./utils"; diff --git a/src/utils.ts b/src/utils.ts index dc2dbf46..72272323 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -103,7 +103,7 @@ export function encodeSpecialCharactersInText(text: string): string { * - 'preeb' and 'posteb' lines are limited to 64 characters, but * should not cause any issues in context of PKIX, PKCS and CMS. */ -export const PEM_FORMAT_REGEX = new RegExp( +const PEM_FORMAT_REGEX = new RegExp( "^-----BEGIN [A-Z\x20]{1,48}-----([^-]*)-----END [A-Z\x20]{1,48}-----$", "s", ); @@ -111,7 +111,7 @@ export const EXTRACT_X509_CERTS = new RegExp( "-----BEGIN CERTIFICATE-----[^-]*-----END CERTIFICATE-----", "g", ); -export const BASE64_REGEX = new RegExp( +const BASE64_REGEX = new RegExp( "^(?:[A-Za-z0-9\\+\\/]{4}\\n{0,1})*(?:[A-Za-z0-9\\+\\/]{2}==|[A-Za-z0-9\\+\\/]{3}=)?$", "s", ); diff --git a/test/public-api-tests.spec.ts b/test/public-api-tests.spec.ts new file mode 100644 index 00000000..e0d86c1f --- /dev/null +++ b/test/public-api-tests.spec.ts @@ -0,0 +1,47 @@ +import { expect } from "chai"; +import * as xmlCrypto from "../src/index"; + +/** + * The published surface used to grow by accident: `index.ts` re-exported `./utils` with + * `export *`, so adding a helper for `signed-xml.ts` to use published it too. The list is + * explicit now, and this test makes widening it a deliberate edit rather than a side effect. + * + * Only runtime values can be checked here — types are erased, but they can only reach the + * surface by being named in `index.ts`, which is a reviewable diff on its own. + * + * @see https://github.com/node-saml/xml-crypto/issues/551 + */ +const PUBLIC_EXPORTS = [ + "C14nCanonicalization", + "C14nCanonicalizationWithComments", + "ExclusiveCanonicalization", + "ExclusiveCanonicalizationWithComments", + "SignedXml", + "createOptionalCallbackFunction", + "derToPem", + "findAncestorNs", + "normalizePem", + "pemToDer", +]; + +describe("Public API surface", function () { + it("exports exactly the documented names", function () { + expect(Object.keys(xmlCrypto).sort()).to.deep.equal(PUBLIC_EXPORTS); + }); + + /** + * `index.ts` only decides what the barrel re-exports. Without an `exports` map every file + * under `lib/` is reachable by path too, and consumers do reach for it, so the entry points + * are pinned here as well. + */ + it("declares only the package entry point and package.json as subpaths", function () { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const manifest = require("../package.json"); + + expect(Object.keys(manifest.exports)).to.deep.equal([".", "./package.json"]); + expect(manifest.exports["."]).to.deep.equal({ + types: "./lib/index.d.ts", + default: "./lib/index.js", + }); + }); +}); From 983e421b9743eba3c3d4193bab7135cb0967b555 Mon Sep 17 00:00:00 2001 From: Chris Barth Date: Thu, 10 Sep 2026 12:22:44 -0500 Subject: [PATCH 2/4] refactor: delete findChilds() instead of only unexporting it `findChilds` is an alias of `findChildren` with no caller in `src/`, so withdrawing it from the barrel still left dead code behind for no one. Co-Authored-By: Claude Opus 5 --- src/utils.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index 72272323..8ecf0437 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -47,11 +47,6 @@ export function findChildren(node: Node | Document, localName: string, namespace return res; } -/** @deprecated */ -export function findChilds(node: Node | Document, localName: string, namespace?: string) { - return findChildren(node, localName, namespace); -} - const xml_special_to_encoded_attribute = { "&": "&", "<": "<", From f2217e77f187563a0018fb378fb0fa0c7ac4bacc Mon Sep 17 00:00:00 2001 From: Chris Barth Date: Thu, 10 Sep 2026 12:22:44 -0500 Subject: [PATCH 3/4] docs: point encoder callers at the canonicalizers, not a serializer The 7.0 upgrade table told callers of the removed encoders to use an XML serializer, but a serializer does not escape the way C14N requires, so a custom canonicalizer that followed the advice would change its digests. Point them at `C14nCanonicalization` and `ExclusiveCanonicalization`, as the 6.x deprecation notice now does. Co-Authored-By: Claude Opus 5 --- README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9da1fe57..f4bd1253 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,13 @@ The package used to re-export everything in its internal `utils` module, so help for `signed-xml.ts` became public API by accident. The export list is explicit now and the following are no longer exported: -| Removed | Instead | -| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `findAttr`, `findChildren`, `findChilds`, `isDescendantOf` | use a DOM API, or [xpath](https://github.com/goto100/xpath) | -| `encodeSpecialCharactersInAttribute`, `encodeSpecialCharactersInText` | these implement [c14n special-character normalization](https://www.w3.org/TR/xml-c14n#ProcessingModel); a serializer such as [xmldom](https://github.com/xmldom/xmldom) escapes for you | -| `isArrayHasLength` | `Array.isArray(x) && x.length > 0` | -| `validateDigestValue` | `crypto.timingSafeEqual(Buffer.from(a, "base64"), Buffer.from(b, "base64"))` — it throws on a length mismatch, which counts as unequal | -| `BASE64_REGEX`, `EXTRACT_X509_CERTS`, `PEM_FORMAT_REGEX` | no replacement; these were internal parsing details | +| Removed | Instead | +| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `findAttr`, `findChildren`, `findChilds`, `isDescendantOf` | use a DOM API, or [xpath](https://github.com/goto100/xpath) | +| `encodeSpecialCharactersInAttribute`, `encodeSpecialCharactersInText` | these are the escaping step of `C14nCanonicalization` and `ExclusiveCanonicalization`, so use those; a custom canonicalizer must apply [C14N escaping](https://www.w3.org/TR/xml-c14n#ProcessingModel) itself | +| `isArrayHasLength` | `Array.isArray(x) && x.length > 0` | +| `validateDigestValue` | `crypto.timingSafeEqual(Buffer.from(a, "base64"), Buffer.from(b, "base64"))` — it throws on a length mismatch, which counts as unequal | +| `BASE64_REGEX`, `EXTRACT_X509_CERTS`, `PEM_FORMAT_REGEX` | no replacement; these were internal parsing details | `derToPem`, `pemToDer`, `normalizePem` and `findAncestorNs` are still exported. See [exports](#exports) for the whole surface. From cf8733431780bbc6d736746b9b78a6a4d8e617a3 Mon Sep 17 00:00:00 2001 From: Chris Barth Date: Thu, 10 Sep 2026 13:28:45 -0500 Subject: [PATCH 4/4] docs: keep validateDigestValue()'s replacement a boolean comparison The 7.0 upgrade table said `crypto.timingSafeEqual()`'s length-mismatch exception "counts as unequal". It throws, so following the advice turns an ordinary mismatch, possibly caused by untrusted XML, into an exception where `validateDigestValue()` returned `false`. Check the lengths first, as the 6.x deprecation notice now says. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f4bd1253..74b1e236 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ following are no longer exported: | `findAttr`, `findChildren`, `findChilds`, `isDescendantOf` | use a DOM API, or [xpath](https://github.com/goto100/xpath) | | `encodeSpecialCharactersInAttribute`, `encodeSpecialCharactersInText` | these are the escaping step of `C14nCanonicalization` and `ExclusiveCanonicalization`, so use those; a custom canonicalizer must apply [C14N escaping](https://www.w3.org/TR/xml-c14n#ProcessingModel) itself | | `isArrayHasLength` | `Array.isArray(x) && x.length > 0` | -| `validateDigestValue` | `crypto.timingSafeEqual(Buffer.from(a, "base64"), Buffer.from(b, "base64"))` — it throws on a length mismatch, which counts as unequal | +| `validateDigestValue` | decode both from base64, then compare with `a.length === b.length && crypto.timingSafeEqual(a, b)` — `timingSafeEqual` alone throws on a length mismatch instead of returning `false`. Never `===` | | `BASE64_REGEX`, `EXTRACT_X509_CERTS`, `PEM_FORMAT_REGEX` | no replacement; these were internal parsing details | `derToPem`, `pemToDer`, `normalizePem` and `findAncestorNs` are still exported. See