cleave-crypto
v0.1.0
Published
Post-quantum hybrid file encryption with key commitment, ratcheted chunk authentication and verifiable erasure. Runs in the browser.
Maintainers
Readme
cleave-crypto
Post-quantum hybrid file encryption for the browser. TypeScript, ESM, no runtime dependencies beyond @noble and WebCrypto.
npm install cleave-cryptoimport { encrypt, decrypt, generateIdentity } from 'cleave-crypto';
const alice = generateIdentity();
const { container } = await encrypt(bytes, {
recipients: [alice.publicKey],
filename: 'notes.txt',
});
const { data, metadata } = await decrypt(container, { identities: [alice] });What it is
CLEAVE is a container format (.clv) built from standardized primitives:
| Role | Primitive | | --- | --- | | Key encapsulation | X-Wing (ML-KEM-768 + X25519) | | Payload encryption | AES-256-GCM via WebCrypto | | Key derivation | HKDF-SHA-512 | | Authentication | HMAC-SHA-512, truncated to 32 bytes | | Passphrase hardening | Argon2id | | Signatures (optional) | ML-DSA-65 over a SHA-512 pre-hash |
No new primitive is introduced. The contribution is at the format layer, and it is four mechanisms:
Key commitment. A tag over the header is verified before any payload byte is decrypted. A container decrypts under exactly one key, so the format cannot be used as a partitioning oracle and cannot be crafted to decrypt differently for different recipients.
Cascade ratchet. Every chunk gets a fresh key, after which the chain key is advanced and the old one erased. Chunk index and finality are bound into both the key schedule and the associated data, so chunks cannot be reordered, duplicated or dropped undetected. This is the STREAM discipline with forward erasure added.
Access strata. An optional preview payload, keyed independently of the main payload. A credential that opens the preview yields nothing about the payload key, so graded disclosure is a cryptographic boundary rather than a policy flag.
Sentinel shard. The master secret can be split into a half stored in the container and a detached 61-byte half. Destroying every copy of the shard makes the container permanently unreadable without touching the container itself.
Recipes
Passphrase, with a cost profile chosen deliberately.
import { encrypt, Argon2Profiles } from 'cleave-crypto';
const { container } = await encrypt(bytes, {
passphrase: userInput,
argon2: Argon2Profiles.sensitive, // 256 MiB, t=4
});Several recipients and a passphrase at once. Any one credential opens it.
await encrypt(bytes, {
recipients: [alicePk, bobPk],
passphrase: fallbackPhrase,
});Sentinel shard. Hand the shard to whoever should hold the power to revoke.
const { container, shard } = await encrypt(bytes, { passphrase, sharded: true });
// Later, to erase: destroy every copy of `shard`. The container is now inert.
const { data } = await decrypt(container, { passphrase, shard });Preview stratum. Wide audience sees the summary, narrow audience sees the file.
await encrypt(fullReport, {
recipients: [boardPk],
preview: { content: 'Q3 summary: revenue up 4%.', recipients: [staffPk] },
});
const summary = await decryptPreview(container, { identities: [staff] });Signing, and requiring a signature on the way in.
const signer = generateSigningIdentity();
await encrypt(bytes, { recipients: [alicePk], signWith: signer });
await decrypt(container, {
identities: [alice],
requireSignatureFrom: signer.publicKey, // unsigned containers now rejected
});Inspecting before prompting. Reads the public header with no credential, so a UI can ask for the right things.
const info = inspect(container);
// { sharded: true, signed: false, slots: { passphrase: 1, recipient: 2 }, ... }Note what inspect cannot tell you: filename, size and content type live inside
the encrypted payload.
Long files on the main thread. Encryption is CPU-bound. Either run it in a
Web Worker, or set cooperative to yield between chunks.
await encrypt(bigFile, { passphrase, cooperative: true, onProgress: (p) => {
setPercent((p.bytesProcessed / p.totalBytes) * 100);
}});Cost
Argon2id in pure JavaScript is slow, and the profile is a real UX decision:
| Profile | Memory | Passes | Rough cost |
| --- | --- | --- | --- |
| interactive | 19 MiB | 2 | a few hundred ms |
| moderate (default) | 64 MiB | 3 | several seconds |
| sensitive | 256 MiB | 4 | tens of seconds |
Parameters are recorded per slot, so a container written at one cost can be re-wrapped at a higher one later without a format change. Recipient slots do not touch Argon2 at all and are attempted first when opening.
Container overhead, measured:
| Configuration | Bytes over plaintext | | --- | --- | | Passphrase only | 237 | | One recipient | 1343 | | Adding a signature | +5269 | | Detached shard file | 61 |
Plus 20 bytes per chunk, with a 1 MiB default chunk size.
Errors
Branch on the class, not the message.
CleaveNoMatchingSlotError— no supplied credential fits any slotCleaveShardError— shard missing, foreign, or corruptCleaveAuthError— commitment or chunk authentication failed: wrong key or tamperingCleaveSignatureError— signature absent when required, or does not verifyCleaveTemporalError— outside the advisory validity windowCleaveFormatError— not a well-formed containerCleaveUsageError— a caller mistake
What this library cannot promise
These are limitations of the setting, not defects to be patched later. Read them before deploying.
Zeroization is best-effort. Uint8Array.fill(0) overwrites one copy. A
JavaScript engine may have made others during garbage collection or JIT
optimization, and nothing in the language exposes them. Do not assume key
material has left process memory.
Delivery is the trust root. If the library reaches the user over the web, whoever controls that channel controls the code, and no property below the delivery layer survives a hostile server. This is the standard objection to browser cryptography and it is correct. Subresource integrity, code signing and extension packaging narrow it; they do not close it. For threat models that include the origin operator, ship a native or packaged client.
Timing is not fully controlled. timingSafeEqual reduces the signal but a
JIT can defeat it speculatively. Lattice operations come from @noble, which
targets constant time, but the runtime provides no guarantee.
Erasure is computational, not information-theoretic. The shard split is a one-time pad, but the container also carries a header tag derived from the master secret and a hint derived from the shard. An adversary with unbounded time could enumerate and confirm. The work is roughly 2^192 with the hint present and 2^256 without. Against any bounded adversary this is irrelevant; against an information-theoretic claim it is fatal, so the claim is not made.
Temporal fields are advisory. notBefore and expiresAt are authenticated,
so they cannot be altered, but nothing prevents an adversary from moving the
clock. They make honest software behave predictably. They are not access control.
Metadata leaks shape. Container length reveals plaintext length to within a chunk. Slot count reveals how many credentials exist, and recipient key identifiers reveal which public keys can open it. If the recipient set is sensitive, this format is the wrong tool.
Not independently audited. The primitives are standardized and their implementations audited. The protocol layer here is not. Treat the version number literally.
No streaming yet, so memory is the real ceiling. Encryption and decryption
are whole-buffer. Measured peak retention is about 1.7 times the plaintext in
ArrayBuffer memory and roughly 3 times in resident set, because the input, the
per-chunk ciphertexts and the assembled container coexist. On a memory-limited
mobile browser that puts the practical ceiling in the low hundreds of megabytes
before the tab is killed. A chunked-streaming API over WritableStream is the
intended next addition; the format already supports it, since the chunk count
has a defined unknown sentinel and truncation resistance rests on the
authenticated final-chunk flag. Until then, do not feed this multi-gigabyte
files in a browser tab.
Startup cost and code splitting
ML-DSA-65 and Argon2id load on first use rather than at import. A container addressed only to recipient public keys touches neither, so a bundler that performs code splitting, which webpack, rollup, vite and esbuild all do for dynamic imports, keeps them out of the initial download. Measured on this build, the recipient-only entry is about 20 KiB gzipped, against 32 KiB for the whole library; the ML-DSA and Argon2 chunks, roughly 3.5 and 4.7 KiB gzipped, arrive only when a signature or passphrase is first used.
If an application knows it is about to prompt for a passphrase or verify a signature, it can warm both during an idle moment:
import { preloadOptionalPrimitives } from 'cleave-crypto';
preloadOptionalPrimitives(); // fire and forgetBecause these primitives load lazily, generateSigningIdentity,
decodeSigningIdentity and any passphrase-based operation are asynchronous.
Compatibility
Requires WebCrypto, which in browsers means a secure context: HTTPS or localhost. Node 20 or later. ESM only.
License
MIT
