Client-side stream cipher — XChaCha20-Poly1305 with an Argon2id password key or an ECIES public key, chunked for arbitrarily large files and streamed so the browser never holds the whole plaintext in memory. Encrypt files and text in the tab (or a Web Worker) and hand the server only ciphertext.
A build-only library (workspace:*, no dev server, no network, no secrets of its
own) consumed by dropply-web (end-to-end encrypted file
sharing, in a Web Worker). All primitives are pure-JS @noble/*
eciesjs, so it runs in a browser, a Web Worker, and Node alike.
Encryption that "protects your data" is worthless if the plaintext still reaches a server. To keep the server zero-knowledge the crypto has to run on the client — and doing that well in a browser is where naive implementations fall over:
- Big files blow the heap.
crypto.subtle.encrypt(wholeFile)buffers the entire plaintext and ciphertext at once.@cdlab/cipherreads theFilein 10 MB slices, encrypts each independently, and streams the results into aBlob— so a multi-GB file never fully materializes in memory. - The UI freezes. A long synchronous crypto loop locks the event loop. This
library yields (
setTimeout(0)) between chunks and reportsonProgress/onStage, so the tab (or worker) stays responsive. - Password vs. key exchange are different problems. Password mode derives the key with Argon2id (memory-hard, tuned params); public-key mode wraps a random key with ECIES and can add a secp256k1 signature — both share one wire format and one chunk pipeline.
- A self-describing format. Every ciphertext begins with a 3-byte magic
prefix, so
detect()can tell password / public-key / signed / unencrypted apart before you pick a decrypt path. - Integrity per chunk. Each chunk carries a SHA-256 hash verified in constant time on decrypt, on top of the AEAD tag — tampering fails loudly.
Part of the @cdlab/projects-monorepo; add it with the
workspace protocol and run scripts through pnpm from the repo root.
{ "dependencies": { "@cdlab/cipher": "workspace:*" } }import { streamCrypto } from '@cdlab/cipher'
// Encrypt a File in the browser → a Blob you can upload or download
const blob = await streamCrypto.encrypt.withPassword({
file, // File
password: 'correct horse battery staple',
onProgress: (pct) => console.log(pct), // 0..100
onStage: (stage) => console.log(stage), // 'Encrypting file...'
})
// …later, decrypt it back
const { file: plaintext } = await streamCrypto.decrypt.withPassword({
file: encryptedFile, // File wrapping the ciphertext
password: 'correct horse battery staple',
})The package ships from dist/ (ESM + CJS + .d.ts); rebuild after editing
source so consumers resolve the new output — pnpm --filter @cdlab/cipher build
(pnpm prepare at the repo root does this in topo order).
const blob = await streamCrypto.encrypt.withPassword({ file, password })
const { file } = await streamCrypto.decrypt.withPassword({ file: enc, password })const blob = await streamCrypto.encrypt.withPublicKey({
file,
receiver: receiverPublicKeyBytes, // Uint8Array — required
sender: { privKeyBytes: senderPrivateKeyBytes }, // optional — signs the file
})
const { file, signatureValid } = await streamCrypto.decrypt.withPrivateKey({
file: enc,
receiver: receiverPrivateKeyBytes, // Uint8Array — required
sender: senderPublicKeyBytes, // optional — verifies signature
})
signatureValidis a returned flag, not a gate — a bad or missing signature does not throw; decryption still completes. Callers must check it.
import { textCrypto } from '@cdlab/cipher'
const { base64 } = await textCrypto.encrypt('secret', 'my-password')
const { text, signatureValid } = await textCrypto.decrypt(base64, 'my-password')Text mode wraps the string in a File and reuses the stream pipeline, returning
a magic-prefixed base64 string ({ blob, base64 } on encrypt).
Single barrel (src/index.ts). Two grouped facades plus the low-level pieces.
| Export | Kind | Description |
|---|---|---|
streamCrypto.encrypt.withPassword |
fn | Encrypt a File in chunks under an Argon2id-derived key → Blob. |
streamCrypto.encrypt.withPublicKey |
fn | Encrypt a File for a receiver ECIES public key; optional secp256k1 signature → Blob. |
streamCrypto.decrypt.withPassword |
fn | Decrypt a password-encrypted File → { file: Blob, signatureValid: undefined }. |
streamCrypto.decrypt.withPrivateKey |
fn | Decrypt a public-key-encrypted File; optional signature verify → { file, signatureValid }. |
textCrypto.encrypt |
fn | Encrypt a string (password or public-key) → { blob, base64 } (magic-prefixed). |
textCrypto.decrypt |
fn | Decrypt a textCrypto.encrypt base64 string → { text, signatureValid }. |
StreamCipher |
class | Per-chunk XChaCha20-Poly1305 + SHA-256 integrity (encryptChunk, decryptChunk, destroy). |
parseStreamHeader |
fn | Parse & decrypt the magic-prefixed header → { header, headerLength, key, signature? }. |
detect |
fn | Read the first 3 bytes of a File/base64 → { encryptionType: 'pwd'|'pubk'|'signed'|'unencrypted', isText }. |
CryptoError, InvalidDataError, DecryptionError, EncryptionError |
classes | Error hierarchy; CryptoError base carries a code. |
ERROR_MESSAGES |
const | Canonical error-message strings. |
CONFIG, MAGIC_BYTES |
const | The frozen format contract (chunk/Argon2/size params + magic prefixes). |
StreamEncryptOptions, StreamDecryptOptions, StreamBaseOptions, StreamHeader, HeaderData, ChunkMetadata, ProgressCallback, StageCallback |
types | Public option / header / callback shapes. |
Password encrypt — src/password.ts (public-key mode is the same loop with an
ECIES-wrapped key and an optional signature, src/publickey.ts):
encrypt.withPassword(file, password)
1. totalChunks = ceil(file.size / 10MB); ext = file extension
2. salt = random(16); key = argon2id(password, salt) onStage 'Generating key'
3. header = magic('ns1') + len + salt + AEAD(JSON{e:ext,c:chunks}) pushed as chunk 0
4. for each 10MB slice: read → onProgress → yield(setTimeout 0)
→ encryptChunk: [uint32 size][sha256 32B] + XChaCha20-Poly1305(chunk)
5. cipher.destroy() wipes the key; return Blob([header, ...chunks])
6. finally: secureClear(key) random-fill then zero
flowchart LR
F["File (plaintext)"] --> S["slice 10MB"]
S --> H["sha256(chunk)"]
S --> E["XChaCha20-Poly1305 encrypt"]
H --> M["[size][hash] metadata"]
E --> M
M --> B["Blob: header + chunks"]
S -->|"more?"| S
Decrypt reads the first HEADER_MAX (2048) bytes, checks the magic, re-derives
the key (Argon2id from the stored salt, or ECIES-unwraps the symmetric key),
recovers the chunk count from the encrypted header JSON, then walks each chunk —
reading its 36-byte [size][hash] metadata, decrypting, and verifying the
SHA-256 in constant time before reassembling the plaintext Blob.
Every value below lives in src/constants.ts and is part of the on-disk
contract — see Non-goals.
| Element | Layout |
|---|---|
Header — password (ns1) |
magic(3) + len(2, LE) + salt(16) + AEAD(header JSON {e,c}) |
Header — public-key (ns0 / signed ns2) |
magic(3) + len(2, LE) + keyLen(2, LE) + eciesEncryptedSymKey + AEAD(header JSON {e,c,s?}) |
| Per chunk (both modes) | [size uint32 LE][sha256 32B] + XChaCha20-Poly1305 ciphertext (24-byte nonce prepended by managedNonce) |
| Constant | Value | Meaning |
|---|---|---|
CONFIG.CHUNK.SIZE |
10 MB | Plaintext slice size per chunk. |
CONFIG.CHUNK.BUFFER |
20 MB | Streaming buffer hint. |
CONFIG.CHUNK.MAX_MEMORY |
100 MB | waitForMemory backpressure threshold. |
CONFIG.ARGON2 |
t=3, m=1280 KiB, p=4 |
Argon2id key-derivation cost. |
CONFIG.SIZES |
SALT=16, NONCE=12, SYM_KEY=32, SIGNATURE=64, HEADER_MAX=2048 |
Byte sizes. |
MAGIC_BYTES |
PASSWORD='ns1', PUBLIC_KEY='ns0', SIGNED='ns2' |
3-byte mode prefixes. |
No network, no env, no bindings, no secrets — all crypto is local and in-memory;
keys/passwords are passed in by the caller. The real external surface is the
primitive set (@noble/*, @scure/base, eciesjs from catalog:prod;
@cdlab/uncrypto is a workspace:* sibling):
| Package | Used for |
|---|---|
@noble/ciphers |
XChaCha20-Poly1305 (managedNonce), byte utils. |
@noble/hashes |
Argon2id key derivation, SHA-256 integrity + file hash. |
@noble/curves |
secp256k1 sign / verify (public-key signatures). |
eciesjs |
ECIES wrap / unwrap of the random symmetric key. |
@scure/base |
base64 for text mode. |
@cdlab/uncrypto |
Cross-runtime getRandomValues shim (browser / Node). |
pnpm --filter @cdlab/cipher build # tsdown → dist/ (ESM + CJS + .d.mts)
pnpm --filter @cdlab/cipher dev # tsdown --watch
pnpm --filter @cdlab/cipher test # vitest --run (happy-dom env)
pnpm --filter @cdlab/cipher typecheck # tsc --noEmitThere is no deploy — it's consumed as a workspace:* package, so a consumer
picks up changes only after dist/ is rebuilt (build, dev --watch, or the
repo-root pnpm prepare). Tests run under happy-dom for the File / Blob /
FileReader APIs; each source module has a matching test/*.test.ts, and
test/polyfill.ts shims globalThis.crypto with Node's webcrypto (imported by
the test files that need it — it is not a global vitest setup file).
- Not a format-versioning scheme. The magic bytes, chunk sizes, and Argon2id
params in
src/constants.tsare a fixed contract. Changing any of them makes every existing ciphertext unreadable — you must bump the magic byte / header version, not edit a constant in place. - Not a key manager. It never generates, stores, or transports keys; the caller supplies passwords and key bytes; ECIES pairs are generated out of band.
- Not a general AEAD wrapper. The API is deliberately
File-centric — string inputs are wrapped intoFileobjects — because its consumers are browser / Web-Worker file tools.
DESIGN.md is the authoritative spec: the wire format, the chunk
pipeline, both key-establishment modes, the integrity / key-hygiene reasoning,
and the format-versioning contract. Read it before touching constants.ts,
header.ts, or stream-cipher.ts — those three files are the on-disk format.