qbit-pqc 0.1.1
Release notes
API
-
A
PqcErrorfromsignWithStatscarriesstatsonly when signing had started: everySIGNING_LIMITerror, and aBAD_SIGNATUREfrom a failed signature or self-check. Before, every native failure carried astatsobject, all zeros when signing failed before it started, which read like an attempt with caps of 0. The wrapper also zeroes the stats buffer before the native call, so a failure that returns without writing the stats can no longer report leftover WASM heap bytes.Compatibility:
statsis nowundefinedonBAD_KEY(for example a secret key whose stored root does not match its seeds),OUT_OF_MEMORY, aBAD_SIGNATUREfrom deriving the signing randomness, and any other failure before signing starts, so code that readserror.stats.<field>unconditionally must check forundefinedfirst.SIGNING_LIMITerrors, the only ones documented to carry stats, keep them, and maintainers chose to ship this in a patch release. -
derivePQCKeyandderiveP2mrDescriptorKey, on the client and as named exports, also read the key pair aspublicKeyandsecretKey, the nameskeygenuses: non-enumerable getters, with no setter, that return whatever the result'spk32andsk64, which stay, hold when read. They are the sameUint8Arrays, not copies, so zeroing either name zeroes both. Both functions now return the new typePqcDerivedKeyPair, which extends the unchangedPqcDerivedKeywith the two read-only fields. Compatibility: the result's runtime shape, enumeration and serialization are unchanged from 0.1.0:Object.keys,JSON.stringifyand deep equality see only{ pk32, sk64 }. A spread,Object.assignorstructuredClone/postMessagecopy of a derived key has onlypk32andsk64, although TypeScript still types the copy as havingpublicKeyandsecretKey, so read the aliases from the original result or copypk32andsk64. TypeScript code that implementsPqcClientitself, such as a fake client, must now return the aliases from its derive methods; the maintainers chose to ship this in a patch release. -
New
./fixtures/*export: the conformance fixtures the package already ships resolve by name, such as@qbit-oss/pqc/fixtures/qbit-core-signature-vectors.jsonand theslh-dsa-golden.json.sha256sidecar, so tests can check keys and signatures against the recorded vectors. Before, resolving one threwERR_PACKAGE_PATH_NOT_EXPORTED. The pattern reaches only files infixtures/. -
The Emscripten glue builds
HEAPU8and its other heap views, when the module is instantiated and whenever a call grows WASM memory, with typed-array constructors captured when the package loads, and computes how far to grow with capturedMathfunctions. Before the factory resolves, it also switches the memory toWebAssembly.Memory.prototype'sbuffergetter andgrowmethod captured the same way, and rebuilds the views through them. It used the globals and the prototype of the moment, so code that replacedglobalThis.Uint8ArrayorMath, or patchedWebAssembly.Memory.prototype, after the package loaded chose the view the wrapper copies inputs and secret keys into and out of, and how much memory a call grew; onceinit()has settled, it no longer can.init()passes the package's copies to the factory asqbitPqcIntrinsics, a new optional member ofPqcWasmModuleOptions, for the default loader,moduleUrlandcreateModule. A factory that wraps the package's should pass it on; for any entry it does not get as a function, the glue uses its own copy, taken when it is imported. Not covered: the growth computation reads%TypedArray%.prototype.lengthandArrayBuffer.prototype.byteLengththrough the prototypes, so patching those accessors can still change how far a call grows memory, or make it fail. Qbit-Org/qbit-pqc#10 tracks these reads. Keys, signatures and the module's exports are unchanged. -
New entry point
@qbit-oss/pqc/external-wasm: the same API as@qbit-oss/pqcwithout the copy ofpqc.wasmthatlib/index.mjsembeds as base64, which keeps about 70 KB out of a bundle that imports only this entry. Itsinit()loadspqc.wasmfromlocateFile, besidemoduleUrl, or the package's ownwasm/pqc.wasmfile, unlesswasmBinarypasses the bytes. The two entries share one module instance: one published client,ready()andisInitialized(), the same named exports,PqcErrorandPqcLoadErrorclasses,constantsanderrorCodes, whichever an app or its dependencies import.@qbit-oss/pqcbehaves as before. The README's MV3 recipe, which passeslocateFile, now importsinitfrom the new entry. Through either entry,init()reads each of its options once per call. Requested in Qbit-Org/qbit-wallet#40. -
New entry points
@qbit-oss/pqc/workerand@qbit-oss/pqc/worker/handlerfor signing off the calling thread (#7).servePqcWorker({ client, endpoint })serves a client, such as the promiseinit()returns, in a dedicated worker;createPqcWorkerClient(endpoint)returns a client whosekeygen,derivePQCKey,deriveP2mrDescriptorKey,sign,signWithStats,verifyandsecretKeyValidaterun in that worker and return promises, withconstantsandclose(). An endpoint is aWorker, aMessagePort, Node'sworker_threadsWorkerorparentPort, or any object withpostMessageand message events. Each client tags its requests with a random nonce, so clients sharing a worker never take each other's results. Byte arguments are copied into buffers that are transferred, so the caller's buffers are never detached; the worker zeroes its copies after each call and transfers results back.optionsis read on the calling side before the request is sent.PqcErrorandPqcLoadErrorare rebuilt as instances of the package's classes, with their fields; aTypeErrororRangeErrorkeeps its type and message. A worker that exits, or a port whose other end closes, rejects the pending calls with anErrornamedPqcWorkerClosedError; a browser worker'serrorandmessageerrorevents go to anonErroroption and leave the client open.close()keeps receiving the answers to the calls it rejected, to zero them; a secondclose()orclose({ force: true })stops at once. The calling side's imports reach neither the Emscripten loader norpqc.wasm;@qbit-oss/pqc/workeralso exportsPqcError,PqcLoadError,errorCodesandconstants, the same objects every entry exports, so code that holds the client checks errors without loading the WASM module. Signing in a worker shows the MV3 offscreen-document relay and lists the copies the package cannot zero, andexamples/worker-signing.mjssigns in aworker_threadsworker. -
init()takes an optionalexpectedWasmSha256, the SHA-256 ofpqc.wasmas 64 lowercase hex characters, and the newconstants.WASM_SHA256is the digest thatwasm/SHA256SUMSpins for this version. With it,init()hashes the bytes it is about to compile, whetherwasmBinary, the embedded copy, or the file thatmoduleUrlorlocateFilenames, and rejects with aPqcLoadErrorthat names both digests before compiling other bytes. It reads such a file itself, withfetch()or, in Node, from the file system, and calls the factory once, with the bytes aswasmBinary. To locate the file, it callslocateFileitself with theprefixthe loader would pass; withcreateModule, that is the package'swasm/directory rather than the directory of the caller's copy of the loader. A baremoduleUrlis resolved withimport.meta.resolve, and rejects where there is none; amoduleUrlthat cannot be resolved rejects rather than falling back to the package'spqc.wasm. In Node, alocateFilethat returns a URL other than afile://URL is refused with an error naming its scheme. These apply only withexpectedWasmSha256. Any other value of the option rejects with aTypeErrorbefore anything loads, and calls with different digests do not share a load. The loaderpqc.mjs, and a factory that ignoreswasmBinary, are not checked. Without the option,init()loads as before.constants.WASM_SHA256is read fromwasm/SHA256SUMSwhen the package is built, and the build fails unlesswasm/pqc.wasm, the copy it embeds, has that digest.
Native library
- The vendored
libbitcoinpqcis pinned toQbit-Org/qbit-libbitcoinpqcatedcea6cb(previously70008b93). Four vendored files change:sha2.candsha2.hgain test-only helpers that the WASM build does not compile, andhash_sha2.candsign.crewrite a zeroing loop so that its counter does not wrap past zero. Keys, signatures and verification results are unchanged; the golden vectors and qbit Core's signatures still match byte for byte. qbit_pqc_sign_with_statszeroesstats_outbefore it rejects its arguments withBAD_ARG, so every return writes the stats. The typed API always passes valid arguments, so nothing it returns changes.wasm/pqc.mjsandwasm/pqc.wasm, and the copy ofpqc.wasmembedded inlib/index.mjs, are rebuilt with these changes and the glue change above. Their SHA-256 values inwasm/SHA256SUMSare now35005303d6ae2c469e0a670664575b99e853c0c7d85bd0c7ba0f65998ede9d06forpqc.mjs(previously4c9f9d9d…) andf7bbbf8ce9cc7d039c1083ddebfd8fdd176864d2bae61b2021eb7f391dd678aaforpqc.wasm(previously8696a3a4…). Integrators who pin these hashes, as qbit-wallet does inscripts/security/sdk-package-pins.json, must update them.
Packaging and docs
- New Supported runtimes page, summarized in the README:
Node
^20.19.0 || >=22.12.0, browsers, Chrome MV3 (service worker, offscreen document, dedicated worker), the bundler versions the bundler smoke builds with, and that React Native, which has no globalWebAssembly, is not supported. It shows how to supply a module built or instantiated differently throughcreateModule, since the loader reads onlylocateFileandwasmBinaryand so ignores Emscripten'sinstantiateWasm. - The implementation moves from
lib/index.mjstolib/core.mjs, and the error classes, codes, constants and byte checks it shares with the worker client tolib/shared.mjs; noexportsentry names either.lib/index.mjsand the newlib/external-wasm.mjsimport them and export only the public API, 0.1.0's names, so helpers the package's own files share are not reachable through an entry. The tarball gainslib/core.mjs,lib/shared.mjsandlib/external-wasm.mjs, with their source maps and declarations, and their sourcessrc/core.ts,src/shared.tsandsrc/external-wasm.ts.typesVersionsmaps@qbit-oss/pqc/external-wasm,@qbit-oss/pqc/workerand@qbit-oss/pqc/worker/handlerto their declarations for TypeScript'snode10resolution, which reads noexportsmap. Compatibility:lib/index.mjsnow imports the files beside it. Bundlers and Node follow those imports, but a setup that copies onlylib/index.mjs, such as an extension that ships the package's files without a bundler, must copy the wholelib/directory. - The tarball gains
lib/worker.mjsandlib/worker-handler.mjs, with their source maps and declarations, their sourcessrc/worker.ts,src/worker-handler.tsandsrc/worker-protocol.ts, anddocs/worker.mdanddocs/runtimes.md. - The README, which is the npm page, leads with install and a runnable quick
start. Its reference text moves unchanged to
docs/api.md,docs/loading.mdanddocs/signing.md. The README keeps a short summary under each old heading, so links to its sections still land.docs/qbit-js.mdshows how to sign PSBTs and message proofs with qbit-js's@qbit-oss/protocol0.1.0. Every export now has a doc comment, solib/index.d.mtsdocuments the whole API in editors. The status lines that said nothing had been published are corrected.
Development
- CONTRIBUTING.md,
.nvmrc, andpnpm verify, which runs every offline check of CI'sverifyjob in order. Prettier (width 120) formats the sources, with the byte-exact paths left alone;wasm/andnative/are kept from line-ending conversion. A newnode-matrixCI job builds and tests on the engines floors 20.19.0 and 22.12.0, and on Node 24 and 26. examples/holds runnable examples thatpnpm smoke:consumerruns against the installed tarball. Every JavaScript and TypeScript code block in README.md anddocs/must match a file that CI runs or type-checks.