Skip to content

feat!: give asynchronous work its own entry points - #564

Closed
cjbarth wants to merge 1 commit into
masterfrom
feat/sync-async-model
Closed

feat!: give asynchronous work its own entry points#564
cjbarth wants to merge 1 commit into
masterfrom
feat/sync-async-model

Conversation

@cjbarth

@cjbarth cjbarth commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Closes #546

Three things want an asynchronous path and none of them had a working one: Web Crypto (crypto.subtle is promise-only), remote keys in an HSM or KMS, and checkSignature's own callback form.

That last one is worth stating plainly — it did not work at all. checkSignature called verifySignature in its three-argument synchronous shape and never passed the callback down. Reproduced on master with an async-only verifier:

Error: sync not supported
    at AsyncOnlyRsaSha1.verifySignature (bug546.js:21:11)
    at SignedXml.checkSignature (lib/signed-xml.js:250:31)

A verifier that answers only through its callback cannot report a valid signature. checkSignatureAsync replaces that path, and there is a test for it citing the issue.

New entry points

computeSignatureAsync, checkSignatureAsync, and validateElementAgainstReferencesAsync join their synchronous counterparts. computeSignatureAsync resolves with the instance so getSignedXml() can be chained.

Written once

Only three operations can be asynchronous, so each flow is a sequence of synchronous phases with two barriers, and the mode lives at the top of two thin orchestrators rather than being threaded through five private methods:

sign:   prepareSignature -> collectReferenceDigests -> [hash] ->
        canonicalize SignedInfo -> [sign] -> finalizeSignature

verify: prepareVerification -> locateReferences -> [hash] ->
        compare digests -> [verify] -> conclude

collectReferenceDigests builds the Reference elements and leaves each DigestValue empty. validateReference splits into locateReference (resolve and canonicalize) and acceptReferenceDigest (compare, record signed content). validateElementAgainstReferences becomes two thin loops over one lazy generator, so a caller that matches on the first reference still does no work for the rest.

Everything between the barriers is shared, so the two entry points produce identical output. Asserted for all four bundled signature algorithms; the pre-existing byte-exact expected-XML test now runs through computeSignatureAsync and still matches to the byte.

Optional async twins

getHash, getSignature and verifySignature become optional and gain getHashAsync, getSignatureAsync, verifySignatureAsync. An implementation provides whichever forms its backend supports and no more — nobody writes both, which is what the issue asks for. Making the synchronous methods optional is what lets an async-only algorithm exist without a throwing stub; the guards below make the failure legible instead.

The asynchronous entry points fall back to a synchronous method, so they accept every algorithm the synchronous ones do, the bundled node:crypto algorithms included.

Fail closed at the wrong entry point

WebCryptoSha256 is async-only; use computeSignatureAsync()
AsyncOnlyRsaSha256 is async-only; use checkSignatureAsync()
NeitherFormSha256 implements neither getHash() nor getHashAsync()

Retiring the callbacks

createOptionalCallbackFunction and ErrorFirstCallback are gone. "Sync unless you pass a callback" was observable: switching signatureAlgorithm changed whether the caller's own try/catch caught a handler's error and whether state assigned after the call was visible to the handler. Node's answer to this is a pair of separately named functions.

The issue asks for the break to be loud. For TypeScript it is a compile error. For JavaScript it would have been silent — signing successfully and never calling back — so computeSignature and checkSignature reject a function argument:

TypeError: The callback form was removed in 7.0; use computeSignatureAsync(), which returns a promise

Open questions from the issue

  • Naming*Async methods on SignedXml, not a separate class.
  • Fix checkSignature's async path in 6.x first? Superseded here rather than repaired; the callback form it belonged to is gone.
  • Bridging helper for implementers? Not added. The asynchronous entry points already accept a synchronous algorithm, which covers the case.
  • Anything else needing to be async? No. Canonicalization and XPath are synchronous throughout; the six crypto call sites were the only ones.
  • Error semantics — every failure in the promise API is a rejection, including configuration errors that throw synchronously today. There is a test asserting computeSignatureAsync with no signatureAlgorithm rejects rather than throwing before the promise settles.

Not changed

Reference digests are still checked before the SignedInfo signature. The TODO about reversing that order is a separate behaviour change and stays a TODO.

Verification

npm run build && npm test && npm run lint clean; 257 passing (241 + 16). All pre-existing tests pass unmodified apart from the one that used the callback form.

🤖 Generated with Claude Code

Three things want an asynchronous path and none of them had a working one:
Web Crypto (`crypto.subtle` is promise-only), remote keys in an HSM or KMS,
and `checkSignature`'s own callback form — which called `verifySignature` in
its three-argument synchronous shape and never passed the callback down, so an
async-only verifier could not report a valid signature at all.

`computeSignatureAsync` and `checkSignatureAsync` join the synchronous pair,
and `validateElementAgainstReferencesAsync` joins `validateElementAgainstReferences`.

The logic is written once. Only three operations can be asynchronous —
hashing, signing, verifying — so each flow is a sequence of synchronous phases
with two barriers, and the mode lives at the top of the two orchestrators
rather than being threaded through five private methods:

  prepareSignature -> collectReferenceDigests -> [hash] -> canonicalize
    SignedInfo -> [sign] -> finalizeSignature

  prepareVerification -> locateReferences -> [hash] -> compare digests ->
    [verify] -> conclude

`collectReferenceDigests` builds the `Reference` elements and leaves each
`DigestValue` empty; `locateReference` resolves and canonicalizes, and
`acceptReferenceDigest` compares. Everything between the barriers is shared, so
the two entry points produce byte-identical output.

The methods on `HashAlgorithm` and `SignatureAlgorithm` become optional and
gain `Async` twins. An implementation provides whichever forms its backend
supports and no more: nobody writes both. The asynchronous entry points fall
back to a synchronous method, so they accept every algorithm the synchronous
ones do; the reverse cannot work, so reaching an async-only algorithm from
`computeSignature` names the entry point that would have:

  WebCryptoSha256 is async-only; use computeSignatureAsync()

The callback overloads and `createOptionalCallbackFunction` are gone. "Sync
unless you pass a callback" was observable: switching `signatureAlgorithm`
changed whether the caller's own try/catch caught a handler's error and whether
state assigned after the call was visible to the handler. Node's answer to this
is a pair of separately named functions, and that is what this is. Passing a
callback now throws a `TypeError` naming the replacement, because signing
successfully and never calling back is the silent break the removal exists to
avoid.

BREAKING CHANGE: `computeSignature(xml, callback)`,
`computeSignature(xml, options, callback)` and `checkSignature(xml, callback)`
are removed, as are `createOptionalCallbackFunction` and `ErrorFirstCallback`.
Use `computeSignatureAsync()` / `checkSignatureAsync()`. `getHash`,
`getSignature` and `verifySignature` are now optional members of their
interfaces; existing synchronous implementations are unaffected, but code that
calls them through the interface type has to account for that.

Closes #546

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 59 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 44de0484-251b-4daf-bd09-2d57a0cb7dd4

📥 Commits

Reviewing files that changed from the base of the PR and between 0409418 and d5a3e1f.

📒 Files selected for processing (6)
  • README.md
  • src/signature-algorithms.ts
  • src/signed-xml.ts
  • src/types.ts
  • test/async-model-tests.spec.ts
  • test/signature-unit-tests.spec.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cjbarth cjbarth added this to the v7.0 milestone Sep 9, 2026
@cjbarth cjbarth closed this Sep 9, 2026
@cjbarth
cjbarth deleted the feat/sync-async-model branch September 9, 2026 23:50
@cjbarth

cjbarth commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Closed in favour of #571 — the same commits, opened from cjbarth/xml-crypto instead of a branch pushed directly to this repo by mistake. The branch here has been deleted.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Design the sync/async model for 7.0 (Web Crypto, remote keys, and retiring the callback overloads)

1 participant