Skip to main content

SQIsign Wasm + WebGPU threat model

This is the attack-surface note referenced from SECURITY.md and AGENTS.md. It describes this package as shipped, not a general isogeny-crypto paper.

:::warning Experimental WebGPU path The loadSqisignLvl*WebGpu() loaders are experimental and not for production until v1.0.0. Today they run the same SQIsign WASM as the standard loaders, optionally in a dedicated Worker, plus a WebGPU warmup that does not perform signing math and does not receive keys. See SQIsign-webGPU. :::

Assets

AssetWhere it lives today
Long-term private keysCaller-owned JS Uint8Array / CryptoKey after keypair() / sign() arguments. The library copies them onto the Emscripten stack for the C call.
Ephemeral keygen / sign seedsJS Uint8Array from crypto.getRandomValues, then copied onto the Emscripten stack.
Isogeny walk / signing stateInside the SQIsign ref C module (Wasm linear memory: stack frames and malloc heap).
GPU storage buffersWarmup only. Filled with the constants 1 and 2, then overwritten and destroy()ed. Not keys.

Returned key objects remain readable by the same JS realm that called keypair. That class of access is out of scope for this library (SECURITY.md Scope).

OPFS encrypted-sk wallet (loadOpfsSkWallet)

Normative browser path for SQIsign, ML-DSA, FN-DSA, and SLH-DSA (including SQIsign *-webgpu algorithm ids). Browser load*().keypair() / .sign() throw on the page and in non-wallet workers. Node / server REST load*() remain unconstrained. verify() is not gated.

RuleAs shipped
IsolationThrows unless self.crossOriginIsolated === true. No main-thread plaintext fallback (unlike the SQIsign-webGPU loader, which may fall back to WASM on the page for warmup/verify).
WrapAES-256-GCM; wrap key is HKDF-SHA-256 of WebAuthn prf.results.first plus the RP salt. Info string qrr-opfs-sk-wrap-v1.
StorageCiphertext + public key in OPFS via a dedicated Worker (qrr-opfs-sk) and FileSystemSyncAccessHandle. Not IndexedDB, not SQLite. Default slot sd-bundle.
UI threadReceives public keys and signatures only. PRF bytes are copied then transferred into the worker and wiped there after use.
GPUUnused. *-webgpu ids still run the same WASM. Do not write sk or wrap keys to GPU buffers.
StringsPrivate keys and PRF material are Uint8Array only.
Mock keygen/signAfter a successful store or sign, a throwaway keypair (and throwaway sign) runs so leftover WASM bytes are more likely mock material. That is pollution, not a claimed HEAPU8 wipe. C malloc / restored C frames remain unwiped.

WebAuthn: opfsSkTriggerWebAuthn() uses userVerification: "required". create() sends prf: {} (no salt — eval on registration options breaks Safari silently and loops Chrome). get() is where prf.eval.first runs, using the RP salt kept out of the RP options blob. Apps using SimpleWebAuthn should do the same. Persist the RP salt and pass { prfOutput, salt, userVerified: true } into the wallet. Caller-owned PRF buffers are not wiped. The wrap CryptoKey is non-extractable and dropped for GC; that is not a byte fill(0).

This shrinks at-rest and idle same-tab dump surface (OPFS holds ciphertext). It does not close: DevTools during the keygen/sign window, XSS in the same worker, variable-time SQIsign ref C, or leftover C-heap bytes after the API returns.

Trust boundaries

  1. Same-tab JS that holds the Uint8Array keys can always read them (DevTools, XSS, a shared closure). Enclave that signer; do not treat Wasm opacity as isolation from the page.
  2. Cross-origin isolation (COOP / COEP) is required for the Worker + SharedArrayBuffer feature gate. Do not remove, weaken, or bypass crossOriginIsolated === true on WebGPU code paths or on loadOpfsSkWallet(). Forks that drop that gate are out of scope (SECURITY.md Scope).
  3. GPU. No key material is written to WebGPU buffers. The COOP/COEP gate is also a mitigation against the cross-tab GPU cache-timing class; putting keys on the GPU later would reopen that class and needs a new review.
  4. Host OS / hypervisor / physical access. Out of scope. This is a userspace library.

What the implementation does

Signing and keygen math for SQIsign, FN-DSA, and ML-DSA is WASM-only. The name *WebGpu is a Worker + warmup label, not GPU arithmetic.

Emscripten stack (JS copies)

withStack() in src/signature-common.ts is the shared wrapper around stackSave / stackAlloc / stackRestore (SQIsign, FN-DSA, ML-DSA, and the accel worker).

On every exit, including thrown errors:

  1. Each JS stackAlloc region is overwritten with HEAPU8.fill(0, …) (length taken from the allocation, not from secret bytes).
  2. The span [min(now, saved), max(now, saved)) is filled, covering alignment padding between the saved stack pointer and the current stackSave().
  3. Then stackRestore runs.

JS-owned ephemeral seeds / sign randomness are fill(0) after withStack returns or throws. Caller-owned private_key buffers are not wiped.

ML-KEM already zeroed its stack slots before stackRestore; signature suites now follow that pattern for JS-copied stack bytes.

WebGPU warmup buffers

warmupWebGpu() writes dummy u32 constants, runs a no-key XOR shader, then overwrites each buffer with zeros and destroy()s it on success and error paths.

What this does not claim

ResidueStatus
JS stackAlloc copies of sk / seedWiped in withStack (success and throw). Covered by unit tests and a live SQIsign L1 module test.
C malloc / libc heap inside the SQIsign ref moduleNot wiped. A full HEAPU8.fill(0) would also destroy module globals (including the test-build CTR-DRBG). Do not treat a heap scan after sign() as a complete secret-absence proof.
Restored C stack framesAfter a C function returns, STACKTOP is typically back at the JS allocations. Bytes the C function used below that pointer can remain until reused. Not claimed wiped.
Constant-time behaviour of the WASM buildUpstream proofs apply to native C. Emscripten output needs its own review (Security & WASM).
GPU signing, GPU constant-time, or keys on GPUNot implemented. Do not enable a GPU field path without a new threat-model revision and human security review.

Tests

pnpm test

Relevant cases:

  • src/signature-common.test.ts — mock heap; secret gone after success and after throw; bytes outside the stack window left intact.
  • src/wasm-stack-zeroize.test.ts — live SQIsign L1 HEAPU8; JS stack marker gone after success and after throw.
  • src/index.test.ts — SQIsign L1 sign→verify also asserts the caller-owned private_key buffer is unchanged.
  • src/opfs-sk/*.test.ts — AES-GCM wrap rejects JS strings; ciphertext does not contain plaintext; COI/Node gate; sealed keygen/sign for ML-DSA, FN-DSA, SLH-DSA, and SQIsign L1; mock keygen does not issue a second store write.

Residual risk (honest)

A process or in-tab memory dump after sign() may still contain private-key bytes in the C heap or in restored C frames. The stack work removes the JavaScript-copied stack slots that were previously left intact after stackRestore alone. It is not a Wasm enclave.

Changes to cryptographic primitives, constant-time code, WGSL shaders, or memory zeroization need a threat-model reference, tests for the invariant, and explicit human security-reviewer sign-off. Agents must not self-merge those changes.