How Encrypti0n.com Works: A Browser-Based Encryption Platform
Encrypti0n.com offers robust, browser-based cryptography that ensures client-side encryption of both text and files. This local-first approach keeps your data secure, preventing unauthorized access during data transfer or storage. Below, we outline clearly and technically how our encryption processes for the Encryption App v3 function internally.
How Encryption Works
All encryption on this site is performed client-side in your browser using @mqxym/cryptit, an open-source MIT-licensed library. No plaintext or password ever leaves your device.
The library uses Scheme 0: a combination of AES-256-GCM for authenticated encryption and Argon2id for password-based key derivation. Every encrypted output is self-describing - it starts with a compact header that stores everything needed for decryption (except your password):
| Header field | Size | Purpose |
|---|---|---|
| Magic byte | 1 byte (0x01) | Identifies the format |
| Info byte | 1 byte | Encodes scheme, salt size & difficulty |
| Salt | 12-16 bytes | Unique random value for key derivation |
The Argon2id preset used for key derivation (middle difficulty) is: 20 iterations, 64 MiB memory, 1 thread - making brute-force attacks computationally expensive.
Text Encryption
Text is encrypted as a single authenticated block. The entire process is performed in memory and produces a compact Base64 string you can copy and share.
- Generate a random salt: A fresh 12–16 byte cryptographic salt is generated for every encryption. It is stored in the header so it can be recovered during decryption.
- Derive a 256-bit key: Argon2id turns your password + salt into a strong 256-bit key. The memory-hard algorithm makes brute-force attempts slow and expensive.
- Encrypt with AES-256-GCM: A random 12-byte nonce (IV) is generated and the text is encrypted. The header bytes are passed as additional authenticated data (AAD), binding them cryptographically to the ciphertext — any tampering with the header will cause decryption to fail.
- Produce the final output: The header, IV, ciphertext and 16-byte authentication tag are concatenated and Base64-encoded for easy copying or storage.
Pseudo-code:
// 1. Key derivation
salt = crypto.getRandomValues(16); // fresh salt every time
header = buildHeader({ scheme: 0, difficulty: "middle", salt });
key = await argon2id(password, salt, { time: 20, mem: 64 * 1024, parallelism: 1 });
// 2. Authenticated encryption — header is bound as AAD
iv = crypto.getRandomValues(12);
ciphertext = await AES_GCM_Encrypt(utf8Encode(text), key, iv, aad: header);
// 3. Output: header + IV + ciphertext + 16-byte auth tag → Base64
return base64(header + iv + ciphertext);File Encryption
Files are encrypted using a streaming, chunk-based pipeline. The file is processed in 512 KiB pieces — large files are never loaded fully into memory, making it possible to encrypt arbitrarily large files without exhausting RAM. Small files are handled in memory using the same pipeline.
- Derive a single key for the entire file: One random salt and one Argon2id key derivation covers the whole file. The salt is written into the header once.
- Stream and chunk: The file is read in 512 KiB chunks. Each chunk is encrypted independently as its own AES-256-GCM block.
- Fresh nonce per chunk: Every chunk gets a new random 12-byte IV. This ensures no two chunks ever share a nonce even if they contain identical data.
- Length-prefixed framing: Each encrypted chunk is preceded by a 4-byte big-endian length field so the decryptor knows exactly how many bytes to read for each frame. The format per frame is:
[4-byte length] [IV (12B)] [ciphertext] [auth tag (16B)]. - Download: The header followed by all encrypted frames is offered as a direct file download.
Pseudo-code:
// 1. Key derivation (once for the whole file)
salt = crypto.getRandomValues(16);
header = buildHeader({ scheme: 0, difficulty: "middle", salt });
key = await argon2id(password, salt, { time: 20, mem: 64 * 1024, parallelism: 1 });
output.write(header); // header is written first
// 2. Stream the file in 512 KiB chunks
for await (const chunk of readChunks(file, 512 * 1024)) {
iv = crypto.getRandomValues(12); // fresh IV per chunk
encrypted = await AES_GCM_Encrypt(chunk, key, iv); // includes 16-byte auth tag
output.write(uint32BE(encrypted.length)); // 4-byte length prefix
output.write(iv + encrypted);
}Decryption
Decryption works for both text and files. The library automatically detects the format from the data structure after the header so no extra input from you is needed.
- Parse the header: The magic byte (
0x01) confirms the format. The info byte reveals the scheme and difficulty, and the salt is read from the following bytes. - Re-derive the key: Argon2id is run with your password and the extracted salt using the same parameters that were used during encryption which produces the identical 256-bit key.
- Detect format and decrypt: If the payload contains length-prefixed frames it is treated as a chunked file; otherwise it is decrypted as a single text block. AES-256-GCM’s authentication tag is verified for every block so any modification to the ciphertext or header is detected and rejected.
Pseudo-code:
// 1. Read self-describing header
header = parseHeader(data); // extracts magic 0x01, scheme, difficulty, salt
key = await argon2id(password, header.salt, header.difficulty);
// 2. Auto-detect text vs. chunked file format
if (isChunked(data)) {
// File: read and decrypt each length-prefixed frame
while (data.hasMore()) {
length = data.readUint32BE();
encrypted = data.readBytes(length); // IV + ciphertext + auth tag
output += await AES_GCM_Decrypt(encrypted, key);
}
} else {
// Text: single AES-256-GCM block; header was bound as AAD
output = await AES_GCM_Decrypt(data, key, aad: header);
}How Local Data Encryption Works
This app stores your data encrypted directly in the browser - no server involved, no plain-text values ever written to localStorage. It uses the open-source MIT-licensed @mqxym/secure-local-storage library, which implements an envelope-encryption scheme: a random Data Encryption Key (DEK) encrypts your data with AES-GCM-256, and the DEK itself is wrapped (re-encrypted) by a Key Encryption Key (KEK) that never leaves the browser as raw bytes.
Protection Modes
The two modes differ only in how the KEK is produced. The data encryption algorithm is identical in both.
The KEK is a non-extractable, origin-bound AES-GCM key generated once and stored in IndexedDB. The store is always unlocked, meaning data is accessible on every page load as long as the user is on the same device and origin. No password prompt, no manual unlock step.
- Device key survives page reloads; lost if browser storage is fully cleared
- Supports periodic key rotation via
rotateKeys() - Export always requires a custom export password (the device key cannot travel)
{
"header": {
"v": 3,
"salt": "", // empty - no password-based derivation
"rounds": 1, // 1 = device mode
"iv": "Zhg/WsAL…", // 96-bit wrap nonce (base64)
"wrappedKey": "AAA…" // DEK encrypted with the device KEK from IndexedDB
},
"data": {
"iv": "V0hzptZK…", // 96-bit data nonce
"ciphertext": "xyz…" // AES-GCM-256 ciphertext of your JSON payload
}
}The KEK is derived from a user-supplied password via Argon2id (20 iterations, 64 MiB memory) and cached in RAM for the current session only. The store is locked on every fresh page load, so data cannot be read or written until the user provides the correct password.
- The password is never stored anywhere; only its derived key lives in RAM during the session
- A 16-byte random salt is embedded in the header; a fresh salt is generated on every password change
- Supports password rotation and removal back to device mode
{
"header": {
"v": 3,
"salt": "FtBViv…", // 16-byte random Argon2id salt (base64)
"rounds": 20, // Argon2id iteration count
"iv": "TQ5fMPx4…", // 96-bit wrap nonce
"wrappedKey": "BBB…" // DEK encrypted with the Argon2id-derived KEK
},
"data": {
"iv": "2E1P2gN9…",
"ciphertext": "abc…"
}
}Setup & Initialization
Create a single instance at application startup. On first run it bootstraps a fresh encrypted store in device mode. On returning visits it reloads the existing configuration and restores the session automatically (device mode) or waits for unlock() (master-password mode).
// --- First run: no existing config in localStorage ---
const dek = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
const deviceKek = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["wrapKey", "unwrapKey"]);
await indexedDB.put("ENC_APP_KEYS", "keys", deviceKek);
const wrapIv = crypto.getRandomValues(new Uint8Array(12));
const wrappedKey = await crypto.subtle.wrapKey("raw", dek, deviceKek, { name: "AES-GCM", iv: wrapIv });
localStorage.setItem("encMainConf", JSON.stringify({
header: { v: 3, salt: "", rounds: 1, iv: base64(wrapIv), wrappedKey: base64(wrappedKey) },
data: { iv: "", ciphertext: "" }
}));
// --- Returning visit (device mode): restore DEK from stored config ---
const { header } = JSON.parse(localStorage.getItem("encMainConf"));
const deviceKek = await indexedDB.get("ENC_APP_KEYS", "keys");
const dek = await crypto.subtle.unwrapKey(
"raw", base64decode(header.wrappedKey), deviceKek,
{ name: "AES-GCM", iv: base64decode(header.iv) },
{ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
);
// --- Returning visit (master-password mode): header.rounds > 1 ---
// No auto-unwrap on page load; isLocked() === true until unlock(password) is called
Reading & Writing Data
setData() accepts any plain JSON-serializable object and encrypts it in one call. getData() returns a read-only SecureDataView proxy; always call .clear() on it when done to wipe the decrypted values from memory. In master-password mode both methods throw LockedError if the store is locked.
// --- Write ---
const plaintext = new TextEncoder().encode(JSON.stringify({ userId: "u_42", preferences: { theme: "dark" } }));
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, dek, plaintext);
const config = JSON.parse(localStorage.getItem("encMainConf"));
config.data = { iv: base64(iv), ciphertext: base64(ciphertext) };
localStorage.setItem("encMainConf", JSON.stringify(config));
// --- Read ---
const { data } = JSON.parse(localStorage.getItem("encMainConf"));
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: base64decode(data.iv) }, dek, base64decode(data.ciphertext)
);
const value = JSON.parse(new TextDecoder().decode(plaintext));
// { userId: "u_42", preferences: { theme: "dark" } }
// Returned as a read-only proxy; call view.clear() to wipe decrypted values from memory
Master Password
All four password operations re-wrap the DEK without ever exposing raw key material. The DEK is made temporarily extractable only for the re-wrapping step, then re-imported as non-extractable.
Transitions from device mode to master-password mode. The DEK is re-wrapped under a freshly derived Argon2id KEK. The session stays unlocked immediately after the call.
const salt = crypto.getRandomValues(new Uint8Array(16));
const kekBytes = await argon2id({ password: "MyStr0ngPassphrase!", salt, iterations: 20, memory: 65536 });
const masterKek = await crypto.subtle.importKey("raw", kekBytes, { name: "AES-GCM" }, false, ["wrapKey"]);
const wrapIv = crypto.getRandomValues(new Uint8Array(12));
const wrappedKey = await crypto.subtle.wrapKey("raw", dek, masterKek, { name: "AES-GCM", iv: wrapIv });
config.header = { v: 3, salt: base64(salt), rounds: 20, iv: base64(wrapIv), wrappedKey: base64(wrappedKey) };
localStorage.setItem("encMainConf", JSON.stringify(config));
// masterKek cached in RAM for this session; next page load will start locked
On every fresh page load a master-password protected store starts locked. unlock() runs Argon2id with the salt and iteration count from the stored header, caches the derived KEK in RAM, and unwraps the DEK. An incorrect password throws ValidationError.
const { header } = JSON.parse(localStorage.getItem("encMainConf"));
const kekBytes = await argon2id({ password: "MyStr0ngPassphrase!", salt: base64decode(header.salt), iterations: header.rounds, memory: 65536 });
const masterKek = await crypto.subtle.importKey("raw", kekBytes, { name: "AES-GCM" }, false, ["unwrapKey"]);
try {
// Wrong password causes AES-GCM authentication tag verification to fail
dek = await crypto.subtle.unwrapKey(
"raw", base64decode(header.wrappedKey), masterKek,
{ name: "AES-GCM", iv: base64decode(header.iv) },
{ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]
);
// masterKek + dek cached in session RAM; getData / setData now work
} catch {
showError("Incorrect password, please try again.");
}
// Lock: discard both keys from memory
masterKek = null;
dek = null;
Verifies the old password, generates a fresh salt, derives a new KEK from the new password, and re-wraps the DEK atomically. Works from both locked and unlocked states.
// 1. Verify old password: derive old KEK and unwrap DEK
const oldKekBytes = await argon2id({ password: "MyStr0ngPassphrase!", salt: base64decode(header.salt), iterations: header.rounds, memory: 65536 });
const oldKek = await crypto.subtle.importKey("raw", oldKekBytes, { name: "AES-GCM" }, false, ["unwrapKey"]);
const rawDek = await crypto.subtle.unwrapKey(
"raw", base64decode(header.wrappedKey), oldKek,
{ name: "AES-GCM", iv: base64decode(header.iv) },
{ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"] // extractable for re-wrap
);
// 2. Derive new KEK from new password with a fresh random salt
const newSalt = crypto.getRandomValues(new Uint8Array(16));
const newKekBytes = await argon2id({ password: "EvenStr0nger#2025", salt: newSalt, iterations: 20, memory: 65536 });
const newKek = await crypto.subtle.importKey("raw", newKekBytes, { name: "AES-GCM" }, false, ["wrapKey"]);
// 3. Re-wrap DEK under new KEK and persist updated header
const newIv = crypto.getRandomValues(new Uint8Array(12));
const newWrappedKey = await crypto.subtle.wrapKey("raw", rawDek, newKek, { name: "AES-GCM", iv: newIv });
config.header = { v: 3, salt: base64(newSalt), rounds: 20, iv: base64(newIv), wrappedKey: base64(newWrappedKey) };
localStorage.setItem("encMainConf", JSON.stringify(config));
Reverses the password setup: the DEK is re-wrapped under the device KEK and the header's salt and rounds are reset to device-mode values. The store must be unlocked before calling this.
// Re-wrap the current DEK under the device KEK
const deviceKek = await indexedDB.get("ENC_APP_KEYS", "keys");
const wrapIv = crypto.getRandomValues(new Uint8Array(12));
const wrappedKey = await crypto.subtle.wrapKey("raw", dek, deviceKek, { name: "AES-GCM", iv: wrapIv });
// Reset header to device-mode values; discard master KEK from RAM
config.header = { v: 3, salt: "", rounds: 1, iv: base64(wrapIv), wrappedKey: base64(wrappedKey) };
localStorage.setItem("encMainConf", JSON.stringify(config));
masterKek = null;
Device Key Rotation
Available in device mode only. Generates a new DEK and a new device KEK, re-encrypts the existing data under the new DEK, and persists the updated bundle. Your plaintext data is preserved; only the cryptographic keys change.
// Generate new DEK and new device KEK
const newDek = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
const newDeviceKek = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["wrapKey", "unwrapKey"]);
await indexedDB.put("ENC_APP_KEYS", "keys", newDeviceKek);
// Re-encrypt existing data under the new DEK
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: base64decode(config.data.iv) }, oldDek, base64decode(config.data.ciphertext));
const dataIv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: dataIv }, newDek, plain);
// Wrap new DEK with new device KEK and persist
const wrapIv = crypto.getRandomValues(new Uint8Array(12));
const wrappedKey = await crypto.subtle.wrapKey("raw", newDek, newDeviceKek, { name: "AES-GCM", iv: wrapIv });
config.header = { v: 3, salt: "", rounds: 1, iv: base64(wrapIv), wrappedKey: base64(wrappedKey) };
config.data = { iv: base64(dataIv), ciphertext: base64(ciphertext) };
localStorage.setItem("encMainConf", JSON.stringify(config));
Use this as a periodic hygiene operation or after a suspected device compromise.
Export & Import
Bundles are self-contained JSON strings that carry the encrypted DEK and the ciphertext together, portable across browsers and devices.
Three variants depending on the current mode and whether you supply a custom password:
// --- Device mode: wrap DEK under an Argon2id key derived from the export password ---
const exportSalt = crypto.getRandomValues(new Uint8Array(16));
const exportKekBytes = await argon2id({ password: "ExportSecret99", salt: exportSalt, iterations: 20, memory: 65536 });
const exportKek = await crypto.subtle.importKey("raw", exportKekBytes, { name: "AES-GCM" }, false, ["wrapKey"]);
const exportWrapIv = crypto.getRandomValues(new Uint8Array(12));
const exportWrapped = await crypto.subtle.wrapKey("raw", dek, exportKek, { name: "AES-GCM", iv: exportWrapIv });
const bundle = JSON.stringify({
header: { v: 3, salt: base64(exportSalt), rounds: 20, iv: base64(exportWrapIv), wrappedKey: base64(exportWrapped), ctx: "export" },
data: config.data // ciphertext copied as-is; only the DEK wrapping differs
});
// --- Master-password mode, no custom password: DEK already wrapped by master KEK ---
// Copy existing header into bundle with export context; no re-wrapping needed
const bundle = JSON.stringify({ header: { ...config.header, ctx: "export", mPw: true }, data: config.data });
// --- Master-password mode + custom password: same Argon2id wrap flow as device mode above ---
// Trigger file download
const a = document.createElement("a");
a.href = URL.createObjectURL(new Blob([bundle], { type: "application/octet-stream" }));
a.download = "app-backup.bin";
a.click();
URL.revokeObjectURL(a.href);
importData() returns a discriminator string that tells you how the bundle was protected so you can handle the subsequent session state correctly.
const bundle = JSON.parse(await selectedFile.text());
const { salt, rounds, iv, wrappedKey, mPw } = bundle.header;
// Derive the unwrap KEK from the supplied password
const kekBytes = await argon2id({ password: "ExportSecret99", salt: base64decode(salt), iterations: rounds, memory: 65536 });
const importKek = await crypto.subtle.importKey("raw", kekBytes, { name: "AES-GCM" }, false, ["unwrapKey"]);
// Unwrap the DEK; wrong password causes AES-GCM authentication tag to fail
const importedDek = await crypto.subtle.unwrapKey(
"raw", base64decode(wrappedKey), importKek,
{ name: "AES-GCM", iv: base64decode(iv) },
{ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]
);
if (mPw) {
// Master-password bundle: persist as-is; instance is now locked
localStorage.setItem("encMainConf", JSON.stringify(bundle));
// caller must call unlock(masterPassword) before accessing data
} else {
// Device-mode bundle: re-wrap imported DEK under the local device KEK
const deviceKek = await indexedDB.get("ENC_APP_KEYS", "keys");
const newWrapIv = crypto.getRandomValues(new Uint8Array(12));
const newWrapped = await crypto.subtle.wrapKey("raw", importedDek, deviceKek, { name: "AES-GCM", iv: newWrapIv });
localStorage.setItem("encMainConf", JSON.stringify({
header: { v: 3, salt: "", rounds: 1, iv: base64(newWrapIv), wrappedKey: base64(newWrapped) },
data: bundle.data
}));
}
Legacy v2 bundles are automatically migrated to the current v3 format with AAD context binding during import.
Clear & Reset
clear() performs a complete wipe: the localStorage bundle is deleted, the device key is removed from IndexedDB, and any in-memory DEK/KEK is discarded. A fresh device-mode store is then initialised automatically and the instance is immediately usable again.
localStorage.removeItem("encMainConf");
await indexedDB.delete("ENC_APP_KEYS", "keys");
dek = null; masterKek = null;
// Reinitialise with fresh keys and an empty encrypted store
const dek = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
const deviceKek = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["wrapKey", "unwrapKey"]);
await indexedDB.put("ENC_APP_KEYS", "keys", deviceKek);
const wrapIv = crypto.getRandomValues(new Uint8Array(12));
const wrappedKey = await crypto.subtle.wrapKey("raw", dek, deviceKek, { name: "AES-GCM", iv: wrapIv });
localStorage.setItem("encMainConf", JSON.stringify({
header: { v: 3, salt: "", rounds: 1, iv: base64(wrapIv), wrappedKey: base64(wrappedKey) },
data: { iv: "", ciphertext: "" }
}));