E2E Encryption
Axfile’s E2E layer encrypts files entirely in the browser before uploading. The server only ever stores ciphertext — it never sees plaintext content, encryption keys, or passwords.
- Cipher: AES-256-GCM (Web Crypto API)
- KDF: PBKDF2-SHA256, 310 000 iterations, 16-byte random salt
- Chunked: each 1 MiB wire chunk decrypts independently, enabling Range-based streaming
Related pages: Whitepaper · Browser sandbox · File and Download APIs
Threat Model
| Threat | Protected? | Notes |
|---|---|---|
| Server compromise | Yes | Server stores only ciphertext. Keys never leave the browser. |
| Passive storage/network sniffing | Yes | AES-256-GCM ciphertext is indistinguishable without the password. |
| Metadata leakage | No | Original filename, file size, content type, and creation date are stored in plaintext so the decrypt UI can display them. Anyone who knows a file’s token can read this metadata without the password. |
| Weak passwords | No | Security depends on password strength. The KDF slows brute-force but cannot prevent it for trivially weak passwords. |
| Browser compromise | No | Malicious extensions or XSS on the host can read keys from memory. E2E assumes a trusted browser. |
| IndexedDB fallback | Degraded | When IndexedDB is unavailable the key is passed via URL query params, leaking it to browser history, proxy logs, and Referrer headers. See IndexedDB Fallback. |
Quick Start
Upload (browser, using built-in ChunkedUploader)
const file = document.querySelector('input[type=file]').files[0];
const uploader = new ChunkedUploader();
const result = await uploader.uploadFileE2E(
file,
'my-secure-password', // password (min 8 chars)
true, // randomizeFileName
null, // expiry (null = no expiry)
8, // linkSize (token length)
false, // keepExtensionInURL
null, // folderId
(msg) => console.log(msg), // progress callback
(hname, link) => console.log(link), // finish callback
);
// result = { success: true, link: "a1b2c3d4", hname: "files.example.com", resourceType: 2 }
Download / decrypt (standalone third-party client)
const fileToken = 'a1b2c3d4';
const password = 'my-secure-password';
// 1. Fetch E2E metadata
const meta = await fetch(`/api/e2e/meta/${fileToken}`).then(r => r.json());
// 2. Derive key from password + salt
const salt = Uint8Array.from(atob(meta.saltB64), c => c.charCodeAt(0));
const keyMaterial = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey'],
);
const key = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: meta.kdfIterations, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt'],
);
// 3. Verify the ciphertext header matches what the server declared
const headerBytes = Uint8Array.from(atob(meta.headerB64), c => c.charCodeAt(0));
const remoteHeader = new Uint8Array(
await fetch(meta.ciphertextUrl, { headers: { Range: 'bytes=0-63' } })
.then(r => r.arrayBuffer()),
);
if (!constantTimeEqual(remoteHeader, headerBytes)) throw new Error('Header mismatch');
// 4. Verify the password by decrypting the sentinel chunk
if (meta.sentinelPlainSize > 0) {
const IV = 12, TAG = 16;
const sentWireLen = IV + meta.sentinelPlainSize + TAG;
const sentWire = new Uint8Array(
await fetch(meta.ciphertextUrl, {
headers: { Range: `bytes=64-${64 + sentWireLen - 1}` },
}).then(r => r.arrayBuffer()),
);
// AAD = header (64 B) || chunkIndex u32le (0) || plainLength u32le
const aad = new Uint8Array(72);
aad.set(headerBytes);
const v = new DataView(aad.buffer);
v.setUint32(64, 0, true);
v.setUint32(68, meta.sentinelPlainSize, true);
const plain = new Uint8Array(await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: sentWire.subarray(0, IV), additionalData: aad, tagLength: TAG * 8 },
key,
sentWire.subarray(IV),
));
// First 17 bytes must be ASCII "AXE2E_SENTINEL_V1"
const marker = new TextDecoder().decode(plain.subarray(0, 17));
if (marker !== 'AXE2E_SENTINEL_V1') throw new Error('Wrong password');
}
// 5. Decrypt file data chunks — see "Range Math" section below
// for how to map a plaintext byte range to ciphertext Range requests.
API Reference
Endpoints
| Endpoint | Auth | Purpose |
|---|---|---|
POST /api/initChunkedUpload |
Session / API key | Create an upload session. Returns { id }. |
POST /api/initChunkedUploadPlain |
username + password in body |
Same, for script/CLI callers without cookies. |
POST /api/uploadChunk?id=…&chunk=N |
Valid upload id |
Upload one wire chunk (multipart file field). |
POST /api/uploadChunkPlain?id=…&chunk=N |
Valid upload id |
Same, plain-auth variant. |
POST /api/finishChunkedUploadE2E |
Session / API key | Finalize E2E upload (JSON body). |
POST /api/finishChunkedUploadE2EPlain |
username + password in body |
Same, plain-auth variant. |
GET /api/e2e/meta/:token |
None (public by token) | Read E2E metadata needed for decryption. |
GET /s/:token |
Public (unless file has a server-side password) | Fetch raw ciphertext. Must support Range. |
e2eMeta — finalize payload
The finalize endpoints expect an e2eMeta object (or metadata) in the JSON body.
Both camelCase and snake_case keys are accepted.
| Field | Type | Required | Description |
|---|---|---|---|
formatVersion |
int | yes | Always 1 for current format. |
cipher |
string | no | Default "AES-256-GCM". |
kdf |
string | no | Default "PBKDF2-SHA256". |
kdfIterations |
int | yes | Must be >= 50 000. Current default 310 000. |
saltB64 |
string | yes | Base64-encoded 16-byte KDF salt. |
headerB64 |
string | yes | Base64-encoded 64-byte E2E header. |
headerLen |
int | yes | Always 64. |
chunkPlainSize |
int | yes | Plaintext bytes per chunk (default 1 048 548). |
sentinelPlainSize |
int | yes | 1024 when sentinel is enabled, 0 otherwise. |
plainSize |
int | yes | Original file size in bytes. Must be > 0. |
cipherSize |
int | yes | Total ciphertext blob size. Must exactly match the reassembled file on disk or the server rejects the upload with 400. |
originalFilename |
string | no | Original filename before encryption. Stored as metadata for the decrypt UI. |
originalContentType |
string | no | Original MIME type. Stored as metadata. |
Response shapes
POST /api/finishChunkedUploadE2E — success (200):
{ "success": true, "link": "a1b2c3d4", "hname": "files.example.com", "resourceType": 2 }
POST /api/finishChunkedUploadE2E — still assembling (202, client should poll):
{ "status": "processing", "message": "Upload is currently being finalized. Please retry shortly.", "retryAfter": 5 }
GET /api/e2e/meta/:token — success (200):
{
"ok": true,
"hname": "files.example.com",
"path": "a1b2c3d4",
"ciphertextUrl": "https://files.example.com/s/a1b2c3d4",
"originalFilename": "report.pdf",
"originalContentType": "application/pdf",
"plainSize": 2097152,
"cipherSize": 2097396,
"formatVersion": 1,
"cipher": "AES-256-GCM",
"kdf": "PBKDF2-SHA256",
"kdfIterations": 310000,
"chunkPlainSize": 1048548,
"sentinelPlainSize": 1024,
"headerLen": 64,
"headerB64": "QVhFMkUwMDE...",
"saltB64": "dGVzdHNhbHQ...",
"createdAt": "2025-01-15T10:30:00.000Z"
}
Error responses (all endpoints): { "error": "..." } with an appropriate HTTP status (400, 404, 413, 500).
Wire Format
Constants
| Name | Value | Derivation |
|---|---|---|
| Header length | 64 bytes | Fixed |
| Header magic | AXE2E001 (8 ASCII bytes) |
Fixed |
| Header version | 1 |
Fixed |
| IV size | 12 bytes | AES-GCM standard nonce |
| GCM tag size | 16 bytes | 128-bit tag |
| KDF iterations | 310 000 | OWASP recommendation for PBKDF2-SHA256 |
| Wire chunk size | 1 048 576 (1 MiB) | Fixed |
| Plain chunk size | 1 048 548 | wireChunkSize - ivSize - tagSize |
| Sentinel plain size | 1 024 bytes | Fixed (or 0 if disabled) |
Header layout (64 bytes, little-endian integers)
Offset Len Field
────── ─── ─────────────────────────────────
0 8 Magic "AXE2E001"
8 1 Version (1)
9 1 IV size (12)
10 1 GCM tag size (16)
11 1 KDF algorithm (1 = PBKDF2-SHA256)
12 4 KDF iterations (310000)
16 4 Plain chunk size (1048548)
20 8 Plaintext file size (uint64 LE)
28 16 KDF salt (random)
44 16 File ID (random, binds header to upload)
60 4 Sentinel plain size (1024, or 0)
Ciphertext blob layout
┌──────────────────────────────────────────────────┐
│ Header (64 bytes, plaintext) │
├──────────────────────────────────────────────────┤
│ Sentinel wire chunk (optional) │
│ [12 B iv] [1024 B ciphertext] [16 B tag] │
├──────────────────────────────────────────────────┤
│ Data chunk 0 │
│ [12 B iv] [≤1048548 B ciphertext] [16 B tag] │
├──────────────────────────────────────────────────┤
│ Data chunk 1 │
│ ... │
├──────────────────────────────────────────────────┤
│ Data chunk N (last, possibly shorter) │
│ [12 B iv] [remainder ciphertext] [16 B tag] │
└──────────────────────────────────────────────────┘
The last data chunk is shorter when plainSize is not a multiple of chunkPlainSize.
Each chunk’s IV is freshly random — never reused.
AAD (Additional Authenticated Data)
Every encrypted chunk (sentinel and data) includes AAD that binds it to this specific file, position, and expected length. This prevents chunk reordering, truncation, and cross-file substitution.
AAD = header (64 bytes) || chunkIndex (uint32 LE) || plainLength (uint32 LE)
Total AAD size: 72 bytes.
chunkIndex assignment:
| Chunk | chunkIndex |
|---|---|
| Sentinel | 0 |
| Data chunk 0 | 1 (when sentinel present) or 0 (when not) |
| Data chunk i | i + 1 (when sentinel present) or i (when not) |
plainLength is the number of plaintext bytes in that chunk (1 048 548 for full chunks, less for the last one; 1024 for sentinel).
Upload Flow
1. Derive the encryption key
salt = 16 random bytes
key = PBKDF2(SHA-256, password, salt, 310000 iterations, 32 bytes)
2. Build the 64-byte header
Fill in all fields from the header layout table above. plainSize is the original file size.
3. Compute the total ciphertext size
sentinelWireSize = sentinelPlainSize > 0 ? (ivSize + sentinelPlainSize + tagSize) : 0
chunkCount = ceil(plainSize / chunkPlainSize)
lastPlainLen = plainSize - (chunkCount - 1) * chunkPlainSize
cipherSize = headerLen
+ sentinelWireSize
+ (chunkCount - 1) * wireChunkSize
+ ivSize + lastPlainLen + tagSize
4. Start the upload session
POST /api/initChunkedUpload
Content-Type: application/json
{ "token": "<accessHash>", "filename": "report.pdf.ax", "size": <cipherSize> }
Returns { "id": "<uploadId>" }.
5. Upload chunks in order
Upload chunks numbered starting from 1. Each is a multipart/form-data POST with a single file field.
chunk= |
What to upload | Encrypted? |
|---|---|---|
1 |
The 64-byte header | No (plaintext) |
2 |
Sentinel wire chunk (if sentinel enabled) | Yes — AAD index 0 |
3 … |
File data wire chunks | Yes — AAD index 1, 2, … (offset by 1 when sentinel present) |
Chunks can be uploaded concurrently (the server reassembles by chunk number). The chunk parameter is 1-based and determines byte order in the final blob.
6. Finalize
POST /api/finishChunkedUploadE2E
Content-Type: application/json
{
"id": "<uploadId>",
"filename": "report.pdf.ax",
"encryptedFilename": "report.pdf.ax",
"manager": "<managerToken>",
"linkSize": 8,
"e2eMeta": { ... }
}
The server reassembles chunks, verifies cipherSize matches (rejects with 400 on mismatch), stores the ciphertext blob, and writes the e2e_file_meta row.
If the server responds with 202 and "status": "processing", wait retryAfter seconds and retry. The client should decrement its retry counter on each 202 to avoid infinite polling.
Download / Decrypt Flow
1. Fetch metadata
GET /api/e2e/meta/<fileToken>
Parse the response to get saltB64, headerB64, ciphertextUrl, plainSize, chunkPlainSize, sentinelPlainSize, kdfIterations, etc.
2. Derive the key
Same KDF as upload: PBKDF2(SHA-256, password, base64decode(saltB64), kdfIterations, 32 bytes).
3. Verify the header
Fetch Range: bytes=0-63 from ciphertextUrl. The 64 bytes must be identical to base64decode(headerB64). This confirms the ciphertext on disk matches the metadata the server stored at upload time.
4. Verify the password (sentinel)
If sentinelPlainSize > 0, fetch the sentinel wire chunk immediately after the header and decrypt it. On success, the first 17 bytes of plaintext will be AXE2E_SENTINEL_V1. On failure, crypto.subtle.decrypt throws — the password is wrong.
This check downloads only ~1 KB instead of the entire file, so a wrong password is caught early.
5. Decrypt file data
For a desired plaintext byte range [start, end]:
- Compute which chunks are needed:
firstChunk = floor(start / chunkPlainSize),lastChunk = floor(end / chunkPlainSize). - For each chunk, compute the ciphertext byte range (see Range Math) and fetch it with an HTTP Range request.
- Decrypt each chunk with AES-GCM using the correct AAD.
- Slice the first and last decrypted chunks to the requested byte window.
- Concatenate and emit.
Range Math
Given the values from the header:
headerLen = 64
ivSize = header byte 9
tagSize = header byte 10
chunkPlainSize = header bytes 16-19 (uint32 LE)
sentinelPlainSize= header bytes 60-63 (uint32 LE)
sentinelWireSize = sentinelPlainSize > 0 ? (ivSize + sentinelPlainSize + tagSize) : 0
wireChunkSize = ivSize + chunkPlainSize + tagSize
wireBaseOffset = headerLen + sentinelWireSize
For file data chunk i (0-based):
plainStart = i * chunkPlainSize
plainLen = min(chunkPlainSize, plainSize - plainStart)
wireLen = ivSize + plainLen + tagSize
wireStart = wireBaseOffset + (i * wireChunkSize)
wireEnd = wireStart + wireLen - 1
Worked example
10 MiB file (plainSize = 10485760), default constants:
chunkPlainSize = 1048548
chunkCount = ceil(10485760 / 1048548) = 10
sentinelWireSize = 12 + 1024 + 16 = 1052
wireBaseOffset = 64 + 1052 = 1116
Chunk 0: wireStart = 1116, wireEnd = 1116 + 12 + 1048548 + 16 - 1 = 1049691
Chunk 1: wireStart = 1116 + 1048576 = 1049692, ...
...
Chunk 9 (last): plainLen = 10485760 - 9*1048548 = 10485760 - 9436932 = 1048828
wireLen = 12 + 1048828 + 16 = 1048856
wireStart = 1116 + 9*1048576 = 9438300
wireEnd = 9438300 + 1048856 - 1 = 10487155
Sentinel
The sentinel is a small encrypted chunk that lets the client verify the password cheaply before downloading the full file.
Plaintext format: 1024 bytes total. Bytes 0–16 are the ASCII string AXE2E_SENTINEL_V1. Bytes 17–1023 are zero.
Wire format: [12 B IV][1024 B ciphertext][16 B GCM tag] = 1052 bytes, stored at ciphertext offset 64 (immediately after the header).
AAD: header(64) || uint32le(0) || uint32le(1024) — chunk index 0, plain length 1024.
Why it exists: Without the sentinel, verifying a password requires downloading and attempting to decrypt the first data chunk (up to 1 MiB). The sentinel reduces this to ~1 KB. For a wrong password, crypto.subtle.decrypt rejects instantly on GCM tag mismatch.
Limitation: Zero-byte E2E files are not supported — both client and server reject plainSize <= 0.
IndexedDB Fallback
The browser E2E decrypt page registers a service worker and passes the derived key to it via postMessage (AX_E2E_REGISTER). The SW stores the key in memory and optionally persists the session in IndexedDB for cross-tab access.
When IndexedDB is unavailable (Firefox private browsing, storage disabled, quota exceeded), the system falls back to encoding key material in the service worker URL as query parameters:
| Param | Contents |
|---|---|
k |
Base64url AES-256 key |
h |
Base64url 64-byte header |
u |
Ciphertext URL |
n |
Filename |
t |
Content type |
c |
Session creation timestamp (ms) |
exp |
Requested session expiry timestamp (ms) |
This weakens E2E guarantees. The key appears in:
- Browser address bar and history
- Proxy / CDN query-string logs
Refererheaders on navigation (mitigated byReferrer-Policy: no-referreron SW responses)- Screen recordings
The browser UI shows a warning when this fallback activates. Third-party clients should use the postMessage channel instead of URL parameters.
Session Lifetime
The service worker holds decryption sessions in memory with two expiration rules:
| Rule | Value | Purpose |
|---|---|---|
| Idle TTL | 30 minutes | Session expires after 30 min of no download requests. Each request resets the idle timer. |
| Absolute max | 7 days | Session expires 7 days after creation even if activity continues. |
During active downloads, the SW periodically refreshes the persisted IndexedDB lease (about once per minute) so very large transfers do not expire mid-stream. Expired sessions are purged from both memory and IndexedDB on the next request.
File Deletion
When an E2E file is deleted (file manager UI or POST /api/edit/:hname/:path with { delete: true }):
- The
resrow is removed. - The
e2e_file_metarow is automatically removed (ON DELETE CASCADE). - The ciphertext file is deleted from disk.
No special E2E-specific deletion endpoint is needed.
Service Worker Routes
The browser decrypt UI registers a service worker that intercepts fetch requests matching these prefixes:
/e2edown/handleFileDecryptage/:token— production decrypt/e2etest/handleFileDecryptage/:token— sandbox/test decrypt
The SW decrypts ciphertext on the fly and streams plaintext back to the browser, supporting Range requests for seeking in media files.
Third-party clients do not need the service worker. Fetch ciphertext directly from /s/:token with Range headers and decrypt in your own code.