@de-otio/crypto-envelope
v0.3.0
Published
Opinionated authenticated-encryption envelopes for TypeScript. XChaCha20-Poly1305 + AES-256-GCM AEAD + AAD binding + HMAC-SHA256 key commitment + RFC 8785 canonical JSON + Argon2id/PBKDF2 passphrase-KDF + SecureBuffer. Safe defaults that can't be turned o
Downloads
584
Maintainers
Readme
@de-otio/crypto-envelope
Opinionated authenticated-encryption envelopes for TypeScript. Makes best-practice cryptography accessible to application developers while preventing common implementation mistakes (nonce reuse, skipped AAD, weak KDFs, silent decryption failure, …).
Status: pre-release (
0.2.0-alpha). AES-256-GCM as a second AEAD, unified passphrase-KDF with brandedMasterKey, strict-by-default browserSecureBuffer, and per-keyMessageCounterwith a 2³² AES-GCM hard cap landed in this line. Extracted from chaoskb — this is chaoskb's encryption layer, packaged separately for audit and reuse. The@latesttag is reserved until chaoskb ships a production release; install the alpha explicitly. The wire format is considered mutable between0.xminors until then.
What it is
An envelope layer above cryptographic primitives (@noble/*, libsodium) and below application protocols (Signal, TLS, JOSE). Takes a plaintext payload + a master key, produces a versioned, authenticated envelope with defensible defaults. Reversibly.
The package is small and opinionated. It does one thing: encrypt and decrypt self-describing blobs. Tiered key management (SSH-wrap, passphrase-derived recovery keys, OS-keychain integration, TOFU pinning) is a separate concern that will land as @de-otio/keyring — unpublished at the time of writing.
What it isn't
- Not a primitives library — use
@noble/*for that, and this package depends on it. - Not a protocol library — use
libsignal,mls, oragefor full sessions, groups, or file encryption. - Not a KMS wrapper — use
aws-encryption-sdk-jsif you need KMS-backed master keys. - Not a JWT/JWE token library — use
jose. - Not a key-management framework — use
@de-otio/keyring(forthcoming) if you want tiered SSH / passphrase unlock, recovery UX, or OS keychain integration.
Install
npm install @de-otio/crypto-envelope@alphaSupported runtimes: Node ≥22, modern browsers (MV3 extensions and pages), Deno ≥2, Bun ≥1, Cloudflare Workers, Vercel Edge. On Node, the package uses sodium-native for mlock'd secure memory (prebuilt binaries; no extra toolchain). On browsers and other WebCrypto-only runtimes, a strict-by-default SecureBufferBrowser is substituted via the "browser" field; constructing one requires an explicit { insecureMemory: true } acknowledgement because browser runtimes cannot mlock.
Quick start
import { EnvelopeClient } from '@de-otio/crypto-envelope';
using client = new EnvelopeClient({ masterKey: crypto.getRandomValues(new Uint8Array(32)) });
const wire = await client.encrypt({ type: 'note', body: 'hello' });
const back = await client.decrypt(wire);
// → { type: 'note', body: 'hello' }encrypt / decrypt are async (the per-key MessageCounter uses a Promise-returning interface so durable backends — SQLite, DynamoDB, Redis — can plug in). wire is a Uint8Array in the compact v2 (CBOR) wire format by default; opt into v1 JSON with { format: 'v1' }, both round-trip losslessly.
Passphrase unlock
import {
EnvelopeClient,
deriveMasterKeyFromPassphrase,
} from '@de-otio/crypto-envelope';
const masterKey = await deriveMasterKeyFromPassphrase(
'correct horse battery staple',
salt, // 16+ random bytes, persisted alongside the ciphertext
{ algorithm: 'argon2id' },
);
using client = new EnvelopeClient({ masterKey });Argon2id is the mandated default (OWASP 2023 second-tier: t=3, m=64 MiB, p=1). PBKDF2-SHA256 is available as a compatibility-only fallback for WebCrypto-constrained runtimes; the iteration floor is 1,000,000 and taking this branch emits a one-time warn.
AES-256-GCM for interop
import { EnvelopeClient } from '@de-otio/crypto-envelope';
using client = EnvelopeClient.forAesGcmInterop({ masterKey });XChaCha20-Poly1305 is the default for every new envelope. Prefer forAesGcmInterop only when decrypting or interoperating with systems that require AES-GCM (or FIPS-constrained environments). AES-GCM carries a 2³² per-key message cap — the client refuses further encryption past this via NonceBudgetExceeded.
wire is a Uint8Array in the compact v2 (CBOR) wire format by default. Opt into v1 JSON with { format: 'v1' }; both round-trip losslessly via upgradeToV2 / downgradeToV1, and decrypt() auto-detects.
For finer control, the low-level functions are exported too:
import {
encryptV1,
decryptV1,
deriveContentKey,
deriveCommitKey,
} from '@de-otio/crypto-envelope';
const cek = deriveContentKey(masterKey);
const commitKey = deriveCommitKey(masterKey);
const envelope = encryptV1({ payload: { x: 1 }, cek, commitKey, kid: 'default' });
const recovered = decryptV1(envelope, cek, commitKey);What this package protects against
Design justification for each feature traces back to a specific class of application-level crypto mistake:
- Nonce reuse → 192-bit random nonces via XChaCha20-Poly1305 (default). AES-256-GCM's 96-bit nonce is available for interop with a hard 2³² per-key message cap enforced at
EnvelopeClient— cross-process counter state is pluggable viaMessageCounter. Nonces are never user-supplied in the public API. - Skipped AAD / version downgrade → AAD is mandatory and binds version + algorithm + blob ID + key identifier.
- Algorithm substitution →
algbound into AAD; nonce-width check rejects cross-algorithm ciphertext at the primitive. - Multi-key / partitioning-oracle attacks → dedicated commitment key via HKDF with its own domain-separation string; commitment HMAC binds to blob ID; verified before AEAD (key-committing, not context-committing — see SECURITY.md).
- Silent serialization drift → RFC 8785 canonical JSON for plaintext + verify-after-encrypt (every output round-trips through decrypt before release).
- Weak KDF parameters → Argon2id at OWASP-2023 second-tier (t=3, m=64 MiB, p=1, dkLen=32) as the mandated default. PBKDF2-SHA256 available for WebCrypto-only runtimes with a 1,000,000 iteration floor and a first-use warning.
- Key confusion →
MasterKeybranded type prevents passphrase-derived bytes from being handed to an AEAD primitive as a CEK without an explicit unbranding cast. - Timing attacks → constant-time comparisons throughout (pure-JS XOR-accumulate; portable across runtimes).
- Keys in swap / crash dumps →
SecureBufferviasodium_malloc/sodium_memzeroon Node. Browsers and other mlock-less runtimes get a strict-by-defaultSecureBufferBrowserrequiring{ insecureMemory: true }at construction — no silent degradation. Math.randomfor keys →globalThis.crypto.getRandomValuesonly; no user-callable RNG for security-sensitive values. Throws on missing WebCrypto rather than falling back.- Silent decryption failure → commitment verified before AEAD; decrypt either returns plaintext or throws.
Published test vectors cover RFC 8785 canonicalisation, RFC 5869 Appendix A.1 HKDF-SHA256, RFC 4231 §4.3 HMAC-SHA256, draft-irtf-cfrg-xchacha §A.3.1 XChaCha20-Poly1305 KAT, an Argon2id cross-implementation KAT against libsodium's crypto_pwhash, RFC 7914 §11 PBKDF2-SHA256 vectors, NIST SP 800-38D / McGrew-Viega AES-256-GCM test cases 13–16, and 66 Wycheproof adversarial AES-256-GCM vectors (keySize=256 / ivSize=96 / tagSize=128).
Error handling
All library errors are instances of EnvelopeError and carry a stable code string suitable for switch statements. Import the classes from the main entry point:
import {
EnvelopeError,
AuthenticationFailedError,
UnsupportedAlgorithmError,
UnsupportedVersionError,
MalformedEnvelopeError,
TruncatedCiphertextError,
NonceBudgetExceeded,
} from '@de-otio/crypto-envelope';Error class hierarchy
EnvelopeError (base — code: string, message: string)
├── AuthenticationFailedError code: 'AUTHENTICATION_FAILED'
├── UnsupportedAlgorithmError code: 'UNSUPPORTED_ALGORITHM'
├── UnsupportedVersionError code: 'UNSUPPORTED_VERSION'
├── MalformedEnvelopeError code: 'MALFORMED_ENVELOPE'
├── TruncatedCiphertextError code: 'TRUNCATED_CIPHERTEXT'
└── NonceBudgetExceeded code: 'NONCE_BUDGET_EXCEEDED'Switching on error codes
import { EnvelopeError, AuthenticationFailedError } from '@de-otio/crypto-envelope';
try {
const plaintext = await client.decrypt(wire);
} catch (e) {
if (!(e instanceof EnvelopeError)) throw e; // rethrow non-envelope errors
switch (e.code) {
case 'AUTHENTICATION_FAILED':
// Wrong key or tampered envelope — indistinguishable by design.
// Do NOT retry with a different key; present a generic "decryption failed" error.
break;
case 'MALFORMED_ENVELOPE':
case 'TRUNCATED_CIPHERTEXT':
// Structural problem before any key material was used.
// Log and discard; the envelope cannot be salvaged.
break;
case 'UNSUPPORTED_ALGORITHM':
case 'UNSUPPORTED_VERSION':
// Envelope was produced by a newer library version.
// Upgrade the library or reject the envelope.
break;
case 'NONCE_BUDGET_EXCEEDED':
// AES-256-GCM per-key cap reached. Rotate the master key.
break;
}
}Partitioning-oracle defence
AuthenticationFailedError is the single error class for all authenticated failures: wrong CEK, wrong commit key, tampered ciphertext, tampered AAD, and tampered commitment. The message and code are intentionally identical for every case. Distinguishing them would allow a partitioning-oracle attack (Len–Grubbs–Ristenpart, USENIX 2021 §4.2): an adversary with decrypt-oracle access could binary-search a candidate key set by observing which failure mode occurred. Callers must treat all AUTHENTICATION_FAILED errors identically.
Maintenance posture
This is a small-organisation, primarily-internal project. Honest expectations:
- This is chaoskb's encryption layer, extracted. Design decisions are made for chaoskb first; other use cases are best-effort.
- Published publicly for transparency and reference, not as a supported product with SLAs.
- Forking encouraged. MIT is permissive on purpose. Wire format + test vectors are designed so a fork can remain interoperable.
- Security issues are responded to on best-effort. See SECURITY.md for the disclosure process.
Development
Requires Node 22+.
npm install
npm run build
npm test # fast suite (~400 ms)
npm run test:slow # Argon2id cross-implementation KAT (~15 s)
npm run lintLicense
MIT.
