V3.1 → V3.12 consolidated and tagged for the first GA release. Wire format unchanged from 0.4.x — 4.0 peers interoperate with 0.4.x peers byte-for-byte. The version bump is semantic: audit-cycle complete, opt-in surface fully exposed, threat model refreshed for every new surface. Highlights: - All 24 @shade/* packages bumped to 4.0.0 in lockstep. - CHANGELOG 4.0.0 section is the canonical manifest of what landed. - THREAT-MODEL extended (§10 fingerprint gates, §11 WebRTC P2P, §12 Web-Worker boundary) + residual-risks table refreshed. - OpenAPI now covers all 27 routes: prekey, transfer, KT, inbox, bridge, observer, /metrics, /healthz, /ready. - MIGRATION 0.3.x → 4.0 documented + smoke-tested against shade migrate-storage on a real SQLite DB. - docs/audit/REVIEW-BUNDLE.md + SCOPE.md ready for external reviewer. - scripts/soak.ts harness for the GA-stable 2-week soak window. - All V*.md plans archived under docs/archive/ with Status: Done. - Voice/Video carved out into V5.0; 4.0 audit focuses on the frozen non-realtime stack. Tests: TS 1000/1000 + Kotlin 11/11 cross-platform vectors green. Docker: gt.zyon.no/stian/shade-prekey:4.0.0 builds and reports version 4.0.0 on /health. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.5 KiB
Web Workers Crypto
Status: Implemented (V3.8 — 0.4.0).
@shade/crypto-web ships with an opt-in dedicated Web Worker that keeps
AES-GCM, HKDF, HMAC, X25519 and Ed25519 — and full per-lane stream state —
off the main thread. Big in-browser uploads (100 MB+) stay smooth without
frame drops.
This doc covers:
- When to use it
- Setup
- API
- Bundler recipes
- Safari notes
- SharedArrayBuffer (COOP/COEP)
- Lifecycle and rotation
- Threat-model considerations
When to use it
The default SubtleCryptoProvider runs on whatever thread you give it.
For the SDK that means the main thread. AES-GCM via SubtleCrypto is fast
(hardware-accelerated), but a 100 MB file at 256 KiB chunks is ~400 AEAD
calls — each one queues a microtask on the main thread. Layered on top of
React reflows and large postMessage payloads to the network worker, you
will see frame drops.
Reach for the Worker pipeline when:
- You upload or download files that don't fit in a single AEAD chunk (≥ ~1 MB) inside a UI-bearing browser tab.
- You generate or rotate identity / device keys in a UI thread that must stay interactive.
- You do batch AEAD (e.g. backup export over many records).
You can keep using SubtleCryptoProvider for short ops (Signal session
encrypt/decrypt for a chat message). The cost of a postMessage round-
trip dwarfs the cost of a single 256-byte AES call.
Setup
@shade/crypto-web exposes the worker as a separate subpath, so your
bundler can resolve it through the standard new Worker(new URL(..., import.meta.url)) idiom.
import { createShade } from '@shade/sdk';
const shade = await createShade({ /* ... */ });
shade.configureWorkerCrypto({
workerUrl: new URL('@shade/crypto-web/worker', import.meta.url),
});
After configureWorkerCrypto, the SDK exposes:
shade.encryptStream({ streamId, streamSecret, ... })— returns aTransformStream<Uint8Array, Uint8Array>and alaneSha256promise.shade.decryptStream({ streamId, streamSecret, ... })— inverse.shade.getWorkerCrypto()— direct access to theWorkerCryptoProviderfor one-off ops (HKDF batches, X25519 batch DH, etc.).
The worker is spawned on first use and self-terminates after
idleTimeoutMs (default 30 s) — no manual lifecycle management required.
API
Stream encryption
const { stream, laneSha256 } = await shade.encryptStream({
streamId: streamId, // 16 random bytes, agreed with peer
streamSecret: streamSecret,// 32 random bytes, derived via Double Ratchet
laneId: 0, // lane index (use multi-lane for parallel HTTP)
chunkSize: 256 * 1024, // optional; default 256 KiB
});
await file.stream()
.pipeThrough(stream)
.pipeTo(transferSink); // your HTTP-shipping WritableStream
const sha256 = await laneSha256; // for end-to-end integrity proof
stream consumes plaintext and emits one wire-encoded
stream-chunk envelope per write. flush always emits a final chunk
with isLast=true (even if the trailing slice is empty), so receivers
see a clean termination.
Stream decryption
const { stream, laneSha256 } = await shade.decryptStream({
streamId,
streamSecret,
laneId: 0,
});
await incomingChunkStream
.pipeThrough(stream)
.pipeTo(fileSink);
const sha = await laneSha256;
if (!equal(sha, peerLaneSha256)) throw new IntegrityError();
Each input chunk MUST be a complete wire envelope. The transport-layer caller is responsible for framing (one envelope per write). Out-of-order or replayed chunks reject the stream — the lane key never crosses thread boundaries, so a man-in-the-middle script in the page can't recover key material to replay against.
Direct provider access
const crypto = await shade.getWorkerCrypto();
// Implements `CryptoProvider` — drop-in replacement for SubtleCryptoProvider
const { ciphertext, nonce } = await crypto.aesGcmEncrypt(key, plaintext);
randomBytes, randomUint32, constantTimeEqual, zeroize execute on
the calling thread (no round-trip). Async ops forward to the worker.
Bundler recipes
Vite
shade.configureWorkerCrypto({
workerUrl: new URL('@shade/crypto-web/worker', import.meta.url),
});
Vite resolves the URL via import.meta.url and emits a discrete chunk
for the worker. No additional config required for Vite ≥ 5.
If your build complains about ?worker syntax, use the explicit URL
form (above) — it's the standard Vite idiom.
Webpack 5 / Rspack
Same idiom — Webpack 5 understands new URL('./worker.js', import.meta.url)
natively as long as the source is ESM:
new Worker(new URL('@shade/crypto-web/worker', import.meta.url), {
type: 'module',
});
For Webpack 4 or non-ESM builds, you need worker-loader (legacy). We
do not officially support Webpack 4.
Rollup
Rollup needs @rollup/plugin-web-worker-loader or a recent
rollup-plugin-import-meta-url. The standard idiom works once the
plugin is wired:
new URL('@shade/crypto-web/worker', import.meta.url)
If your bundler can't resolve @shade/crypto-web/worker, copy
node_modules/@shade/crypto-web/src/worker.ts (or the compiled .js
once we ship dist artefacts) into your public/ directory and pass an
absolute URL:
shade.configureWorkerCrypto({ workerUrl: '/shade-crypto.worker.js' });
Safari notes
Safari ≤ 17 has a smaller postMessage transferable budget than Chrome /
Firefox. Single transfers above ~64 MB occasionally fail silently. The
shipped pipeline already chunks plaintext to 256 KiB before AEAD, so
each postMessage carries ≤ ~256 KiB + AEAD overhead — well under any
known Safari limit.
If you override chunkSize, keep individual buffers below 16 MiB:
shade.encryptStream({
streamId, streamSecret,
chunkSize: 8 * 1024 * 1024, // 8 MiB — safe across all browsers
});
We do not officially support Safari ≤ 14 (no module workers).
SharedArrayBuffer (COOP/COEP)
The default pipeline uses ArrayBuffer transfer (zero-copy ownership
hand-off). It does not require COOP/COEP headers.
For multi-lane parallel transfers across multiple workers, you may opt
in to SharedArrayBuffer for the AEAD plaintext buffers. That requires
your origin to serve:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
SharedArrayBuffer support is gated behind a future useSharedBuffers
option and is not enabled in V3.8. See docs/V4.0.md if/when this lands.
Lifecycle and rotation
const crypto = await shade.getWorkerCrypto();
await crypto.rotate(); // tear down the current worker, respawn lazily
await crypto.destroy(); // permanent — every subsequent call rejects
shade.shutdown() calls destroy() automatically. The idle-timer fires
30 seconds after the last response (configurable via
configureWorkerCrypto({ idleTimeoutMs })); if the timer fires while
calls are pending, it does nothing and reschedules.
Threat-model considerations
- The worker runs in the same origin and the same browsing context as
the main thread. It is not a sandbox against a compromised page;
any script that can
evalin your tab can alsopostMessageto the worker. The Worker is a performance boundary, not a security boundary. - Lane keys derived inside the worker stay there; they are never postMessage'd to the main thread. This narrows the window during which a key sits in main-thread heap, which helps against post-mortem heap inspection by a curious extension. It does not help against an active in-page attacker.
randomBytesruns on the calling thread (usescrypto.getRandomValuesdirectly). The worker has its own random source for ops that derive inside it (nonces are derived deterministically from(laneId, seq)).
For the full picture, see THREAT-MODEL.md.
Verifying main-thread budget
V3.8 acceptance: 100 MB upload in Chrome without main thread blocked
16 ms in P99.
To verify in your app:
- Open Chrome DevTools → Performance.
- Record a 100 MB upload.
- Inspect the main-thread flame chart. Look at "Long Tasks" and
"Self time" of
Shade.encryptStream. - Confirm no contiguous block exceeds ~16 ms (one frame at 60 fps).
If you observe long tasks, lower chunkSize (more frequent yields) or
report the trace — see docs/archive/V3.8.md for
the original acceptance criteria.