API Documentation

E2E Client Integration

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]:

  1. Compute which chunks are needed: firstChunk = floor(start / chunkPlainSize), lastChunk = floor(end / chunkPlainSize).
  2. For each chunk, compute the ciphertext byte range (see Range Math) and fetch it with an HTTP Range request.
  3. Decrypt each chunk with AES-GCM using the correct AAD.
  4. Slice the first and last decrypted chunks to the requested byte window.
  5. 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
  • Referer headers on navigation (mitigated by Referrer-Policy: no-referrer on 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 }):

  1. The res row is removed.
  2. The e2e_file_meta row is automatically removed (ON DELETE CASCADE).
  3. 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.