qbit-pqc 0.1.0
Release notes
This was the first release, and nothing had been published before it. Changes
are listed against version 0.0.0, the unreleased state before the
pre-release review. Breaking marks a change that can make existing
caller code fail or behave differently.
API
- Breaking: a byte input (
entropy,masterSeed,sk,pk,msgorsig) whoseArrayBufferhas been detached, or that a shrunk resizable buffer has pushed out of bounds, is rejected with aTypeError. Before, such a view read as empty, sosignsigned the empty message andverifycould accept a signature over the empty message. - Breaking:
signandsignWithStatsreject a secret key whose stored root does not match its seeds withkind: 'BAD_KEY'instead of'BAD_SIGNATURE';secretKeyValidatereturnsfalsefor the same bytes. An allocation failure inside the native library throws the new kind'OUT_OF_MEMORY'(code-6).PqcErrorKindandPqcNativeErrorCodegain these members, so an exhaustiveswitchover them needs a new case. - Breaking:
verifyreturnsfalseinstead of throwing aRangeErrorwhenpkis not 32 bytes orsigis not 3680 bytes, as qbit Core'sCPQCPubKey::Verifydoes. It decides this before allocating any WASM memory, so a key or signature of the wrong length, however long, neither throws nor costs a copy. It still throws aTypeErrorfor an argument that is not aUint8Arrayor is detached or out of bounds, and aPqcErrorfor a native failure other thanBAD_SIGNATURE. Code that caught theRangeErrorto report malformed input can compare the lengths withconstants.PK_SIZEandconstants.SIG_SIZEfirst. - Breaking:
signandsignWithStatsthrow aRangeErrorfor amsglonger than 64 KiB (65536 bytes, the newconstants.MAX_MESSAGE_SIZE), andverifyreturnsfalsefor one, unless the caller passes a new optional last argument,{ allowLargeMessage: true }. Every grind attempt rehashes the whole message, so signing 1 MiB took one to two minutes, and verifying a large message grew the WASM heap for the instance's lifetime; qbit Core signs and verifies only 32-byte sighashes. Both decide before allocating any WASM memory. Signatures and verification results for messages up to 64 KiB are unchanged. The new argument must beundefinedor a plain object whose only property, if any, is a booleanallowLargeMessage; any other value in that position, which used to be ignored, throws aTypeError. - Breaking:
init({ moduleUrl })loadspqc.wasmfrom the directory of thepqc.mjsthatmoduleUrlpoints at. Before, it always loaded the package's ownwasm/pqc.wasm, so serving both files from another directory neededlocateFileorwasmBinary, and apqc.mjscopied without itspqc.wasmstill loaded; that now fails. A customlocateFilethat returnsundefinedornulldefers to that same default: besidemoduleUrlwhen it is set, the package's copy otherwise. Calls withoutmoduleUrl, or withwasmBinaryorcreateModule, behave as before. - Breaking: when zeroing a WASM allocation fails while a call cleans up,
as it does once a module from a custom
createModulefactory keeps aHEAPU8view that went stale when WASM memory grew, the rest of the cleanup still runs, so every allocation is freed, and the call throws its own error, such as thePqcErrorof a failed native call or theTypeErrorfor an input detached during the call. Before, the first failure skipped the rest of the cleanup, leaking those allocations, and itsTypeErrorreplaced the call's error. A call that had otherwise succeeded still throws the cleanup's error, now after freeing everything. - Breaking:
init()loads apqc.wasmfile only when told where it is. The defaultinit(), andinit({ createModule })withoutlocateFileorwasmBinary, compile a copy ofwasm/pqc.wasmthat the build embeds inlib/index.mjsas base64: the same binarywasm/SHA256SUMSpins, which a test andpnpm smoke:consumercheck. Before, they fetched the package'swasm/pqc.wasm(in Node, read it from disk), so a page now makes no request for it.moduleUrl,locateFileandwasmBinarywork as before, and onlymoduleUrlandlocateFilemakeinit()load the file.lib/index.mjsgrows by about 70 KB (25 KB gzipped). - Breaking:
PqcErrorKindgains'NOT_INITIALIZED'and'LOAD_FAILED', so an exhaustiveswitchover it needs new cases. A named export called before anyinit()call has succeeded, including after a failed firstinit(), throws aPqcErrorwithkind: 'NOT_INITIALIZED'andcode0 instead of a plainError; the message is unchanged. Code that turns any thrown error fromverifyintofalsecanawait ready()first. - Breaking: when
init()cannot load the WASM module, it rejects with the newPqcLoadError(kind: 'LOAD_FAILED',operation: 'init',code0). That covers a loader that cannot be imported or exports no factory, apqc.wasmthat cannot be fetched, compiled or instantiated (the embedded copy included, as under a CSP without'wasm-unsafe-eval'), a module that does not match this version of the package, and a failingcreateModule. Itscauseis the errorinit()rejected with before: Emscripten's abort (RuntimeError: Aborted(...)), the import error, the factory's error, or the package's ownTypeErrororError. Itsurlnames themoduleUrlloader or the location the loader asked forpqc.wasmat, and is undefined when there is no file to name; the message names both. In a browser,init()requests anhttp(s)orchrome-extension:urlonce more, within 10 s, only to report the HTTP status of an error response asstatus. - Breaking:
init()calls with the same options share one load and resolve to the same client; before, every call instantiated a new 17 MB module. Options are the same whenmoduleUrlhas the same string form andcreateModule,locateFileandwasmBinaryare the same objects, and a failed load is not shared. The named exports use the client of the newest call that succeeded; before, an older call that settled last replaced it. A failed call still leaves the published client in place. Code that needs a module of its own can pass a newcreateModulefunction. - Breaking: the client
init()resolves with is frozen and has nomoduleproperty. Assigning to one of its methods throws aTypeErrorin strict mode and does nothing otherwise, and spying on one with a test framework fails the same way. Before,client.verify = () => true, or a spy left in place, made every namedverifyin the realm returntrue, andclient.modulehanded out the raw WASM exports, which skip the wrapper's input checks and the zeroing of its copies in WASM memory. Code that needs the module, such as a test that instruments it, can build it with the factory@qbit-oss/pqc/wasm/pqc.mjsexports, pass that throughinit({ createModule })and keep its own reference.constants,errorCodesand its aliaserrorsare frozen, nested objects included, so a write to them throws in strict mode instead of changing what other code reads.nativeErrorNameandnativeErrorKindno longer read inherited keys:nativeErrorKind('constructor')returns'UNKNOWN'instead ofundefined. - New
isInitialized()andready(): whether a client is published, and a promise for it that resolves as soon as one is. init()importswasm/pqc.mjsby a literal relative path instead of a URL computed at run time, so bundlers include the loader. With the embedded copy, the defaultinit()and the static-factory recipe work with no bundler configuration in webpack 5, esbuild (--platform=browser, ESM and IIFE output) and Vite (production build and dev server). Before, webpack and esbuild failed to build the recipe on the loader'simport("module"), the defaultinit()threw at run time in their bundles, and Vite's dev server requested/node_modules/.vite/deps/undefinedfor both. Wheninit()has the WASM bytes, its defaultlocateFileno longer readsimport.meta.url, soinit({ wasmBinary })also works where a bundle leavesimport.metaempty, as an esbuild IIFE bundle does.- Byte inputs are recognized and measured through
%TypedArray%accessors captured when the module loads. Alengthgetter that changed between reads could makesignsign different bytes from the caller's and write past a WASM heap allocation; an overriddenlength, a replaced prototype or aProxytrap can no longer change how many bytes are signed, verified or copied, and validation runs no caller code.Uint8Arrays from another realm (an iframe, a worker or anode:vmcontext) are now accepted. derivePQCKeyandderiveP2mrDescriptorKeyhash a copy of the seed taken right after validation, and their SHA-256, HMAC-SHA-256 and HKDF call only%TypedArray%andDataViewmethods and getters captured when the module loads. Patching those prototypes can no longer change the derived key, keep the PRK and keygen entropy from being zeroed, or run code during the hash. Derived keys are unchanged.- New
deriveP2mrDescriptorKey(leafPrivateKey, rangeIndex), exported and on the client frominit(), returns the P2MR key qbit Core's descriptor wallet derives (P2MRPubkeyProvider::GetPubKey):derivePQCKey(leafPrivateKey, 0, 0, rangeIndex)for a 32-byte BIP32 leaf private key and a non-hardened range index (0to0x7fffffff). A leaf of any other length (a 31-byte unpadded key, a 33-byte xprv-style key, a 64-byte BIP39 seed), a leaf that is zero or not below secp256k1's group order (for example a wiped buffer), or a hardened index throws instead of deriving a key Core never generates.derivePQCKeyremains the low-level primitive, and its parameters are renamedhkdfAccount,hkdfChangeandhkdfIndex: they are HKDF fields, not BIP32 path fields. Its behavior is unchanged.
Native library
- The vendored
libbitcoinpqcis pinned toQbit-Org/qbit-libbitcoinpqcat70008b93(previouslyd165da6a). The new pin adds theBAD_KEYandOUT_OF_MEMORYreporting above and clears the signature buffer when the low-levelslh_dsa_signrejects its arguments. Keys, signatures and verification results are unchanged; the golden vectors and qbit Core's signatures still match byte for byte.
Packaging and docs
- The package is published as
@qbit-oss/pqc, with thewasm/pqc.mjsandwasm/pqc.wasmsubpaths.repository,bugsandhomepagepoint at the Qbit-Org repository, andpublishConfigrequires npm provenance. - Breaking:
engines.nodeis^20.19.0 || >=22.12.0, the releases whererequire()loads ES modules by default. Package managers that enforceengines, such as Yarn 1 or npm withengine-strict, refuse to install the package on older Node versions. - The package ships
THIRD_PARTY_NOTICESfor the code linked intopqc.wasm(libbitcoinpqc, SPHINCS+, Emscripten, musl and dlmalloc) and exports./LICENSEand./THIRD_PARTY_NOTICES. Redistribute both with the WASM artifacts. require('@qbit-oss/pqc')works:.gains adefaultcondition that loads the ES module entry throughrequire(esm), so CommonJS and ES module callers share one instance, andinit()through either sets up the top-level exports for both. There is no separate CommonJS build.exportsalso adds./package.json,./wasm/SHA256SUMSand type declarations for./wasm/pqc.mjs(types/pqc.d.mts), so the README's MV3 snippet type-checks undertsc --strictwithout a local declaration shim.- The README's initialization section covers sharing, publication, the new
errors,
isInitialized()andready(). A service-worker section says to callinit()when the worker starts, that the client does not survive a restart, that signing blocks the thread for about 0.6 s, and how to sign in a Worker started by an offscreen document.SECURITY.mdstates thatwasm/SHA256SUMSis release evidence, not a runtime check. package.jsonhas abrowserfield that mapsmodule, the Node builtin that only the loader's Node branch imports, to an empty module, so browser builds with webpack and esbuild no longer fail to resolve it and Vite no longer warns about it.- The README's loading section describes the embedded default, when
pqc.wasmis still loaded as a file (moduleUrl,locateFile), and why service workers need the static-factory recipe.SECURITY.mdanddocs/mv3-conformance.mdstate that the embedded copy is the pinned binary. - The README documents the 2^30-signature usage bound of a bounded30 key, which
this package does not enforce, when re-signing reveals nothing new, and how
the bound differs from
SIGNING_LIMIT. - The README says to sign 32-byte digests and gives measured signing times by
message length.
docs/derivation.mdexplains that the cost comes from FORS+C grinding overH_msgand cannot change without a consensus change. docs/derivation.mdstates that this package does not compute qbit's P2MR signing preimage.SECURITY.mdlists what the package zeroes (every WASM allocation, the derivation's JavaScript buffers, native intermediates and released stack) and what it cannot: returned keys and signatures, and the caller's inputs.SECURITY.mdstates that the package has not had an independent security audit, and that no review of side channels or constant-time behaviour has been done for its WASM build.docs/derivation.mdstates that qbit Core derives a non-rangedpqc(...)descriptor's key with HKDF index0whatever the leaf's path, and that the path's change field never enters the HKDF tuple.- A publish workflow publishes each
vX.Y.Z-rc.NorvX.Y.Ztag onrelease/X.Ywhose version has a CHANGELOG section, under the dist-tagcandidateorlatest, with npm provenance, after the same checks as CI. Itsbuildjob packs the tarball running only code the lockfile pins, itssmokejob runs the checks that fetch data or run code outside the lockfile, and only itspublishjob, which a maintainer approves, can publish: it runs no repository or dependency code, publishesbuild's tarball only if it matches the SHA-256 sumbuildrecorded, and on a re-run skips a version npm already holds with the same bytes and fails on different ones.scripts/check-publish.mjs, whichbuildruns andnpm publishruns asprepublishOnly, refuses package files that differ from the commit, WASM that failscheck:wasmor, outside CI,check:wasm:repro, and a version that is0.0.0or not of those two forms.pnpm pack:checkrequires every packed file except tsup's threelib/outputs to be tracked by git, and warns about uncommitted changes.
Development
- CI checks that every vendored C file is blob-equal to the pinned upstream
commit, against both
MANIFESTand a fetch of that commit. - Tests pin qbit Core's own SLH-DSA signatures and the key derivations of a qbit Core release binary, and check fixtures against their SHA-256 sidecars.
- After every operation, on its success and error paths, tests search all of
WASM linear memory for fragments of the secret key, the keygen entropy,
derivePQCKey's master seed and derived entropy, and the seed signing derives from the key. They also paint the stack and check that no native call taking a secret writes below the 4 KiB of stack the library zeroes before returning. - Tests compare the SHA-256, HMAC-SHA-256 and HKDF that
derivePQCKeyimplements in TypeScript withnode:crypto, for inputs of 0 to 200 bytes and one over 64 KiB and for HMAC keys longer than a block, and checkderivePQCKeyitself at every seed length that lands on a SHA-256 padding boundary. - Tests patch each
%TypedArray%andDataViewprototype member the derivation hash used, or install alengthgetter that would detachmasterSeedmid-call, and checkderivePQCKeyagainst qbit Core's vectors and that it zeroes every buffer it allocates. Others make zeroing fail during cleanup and check that each call frees everything and throws its own error. - Tests cover
init()ordering, sharing, failed loads,NOT_INITIALIZED,isInitialized(),ready()andPqcLoadError, including a compile failure of the embedded copy and the status request outside Node with a stubbedfetchand fake timers. Tests that need a module of their own get one fromtest/fresh-client.ts. - Tests check that the client,
constantsanderrorCodesare frozen, that writes to them throw in strict mode and change nothing the wrapper does, and that the client has nomodule. Tests that call or patch the WASM module directly build it and pass it toinit({ createModule }):test/fresh-client.tsreturns that module with the client, andpnpm smoke:loadbuilds its own. pnpm smoke:bundlerspacks the package, installs the tarball with exact versions of webpack, esbuild, Vite 7 and Vite 8 into throwaway projects, with npm's--beforeholding their dependencies to releases published by a fixed date, and builds pages with each bundler's stock settings. The pinned Chrome for Testing runs every page, which must match the golden vectors through the defaultinit()and throughinit({ createModule })without requestingpqc.wasm. It runs as its own CI job,bundler-smoke, because it downloads the bundlers.- The MV3 smoke runs as its own CI job against a pinned Chrome for Testing
(
156.0.8078.47). Unless--chromeorCHROME_FOR_TESTING_PATHnames a binary,pnpm test:mv3uses only that build for the current platform in the repository's.context/browsers: it no longer falls back to a system install or searches parent directories.CHROME_NO_SANDBOX=1runs Chrome without its sandbox on hosts that block unprivileged user namespaces. pnpm check:wasm:reprorefuses to run withUSE_LOCAL_EMCC=1,EMSDK_IMAGEorOUT_DIRset unlessQBIT_PQC_REPRO_ALLOW_OVERRIDES=1is also set, and its pass message then lists them. The WASM build prints theemccversion and the Docker image digest, rejects anOUT_DIRoutside the repository for the Docker build, and deletes the oldpqc.mjsandpqc.wasmbefore compiling. Its-fstack-protector-strongflag is now documented as not a security control in WASM; it stays because removing it changespqc.wasm.pnpm smoke:consumerhandles SIGINT and SIGTERM: it stops the consumer process, reports where it kept the stage directory, and exits with 130 or 143.- pnpm 10.34.6, which CI reads from
packageManager, with vitest 3 and tsup 8. - Release process:
RELEASING.md, this changelog, and CI onrelease/**branches. pnpm typecheckalso checks the README's MV3 snippet against the package's ownexportsmap undernode16andbundlerresolution, buildinglib/first for it.pnpm smoke:consumerchecksrequire(),./package.json, and that./wasm/SHA256SUMSmatches the installed artifacts.