Skip to navigation

Zero-Knowledge Encryption

View as Markdown

How it works

Konfidant encrypts every share on the sender’s device, before anything is sent to the API. The API stores and delivers ciphertext only.

  1. The client generates a fresh random 256-bit key for the share and encrypts the content with AES-256-GCM in the KNF1 format. For files, the file name and MIME type are encrypted together with the content.
  2. The client sends only the ciphertext to the API (POST /api/v1/texts, or the three-step file flow). The API returns a download_url that carries a server-issued, single-use token in the URL fragment: #t=<token>.
  3. The client appends the key to the fragment and sends the resulting share link to the recipient:
https://download.konfidant.app/#t=<urlencoded token>&k=<base64url key>
  1. The recipient opens the link. The page reads the fragment, sends only the token to Konfidant, receives the ciphertext once and decrypts it locally. The ciphertext is deleted after it has been delivered.

The URL fragment (everything after #) is never sent to a server by browsers, so the key never reaches Konfidant. Organizations with a custom download domain receive links on that domain instead of download.konfidant.app.

The download_url returned by the API is not a working share link on its own. Without &k=<key> the recipient cannot decrypt the content. Conversely, the full share link is the secret: anyone holding it can open the share once. Do not log it.

What Konfidant can and cannot see

Konfidant never receivesKonfidant does learn
The plaintext text or file contentThe ciphertext size (roughly the content size)
The file name and MIME typeWhen the share was created, by which API key or user
The decryption keyWhen the share was opened, and when it expires

Consequences:

  • Lost links cannot be recovered, not even by Konfidant support: there is no copy of the key.
  • The single-use token is issued by Konfidant and is not a secret from Konfidant. It only controls access to the ciphertext; the key is what protects the content.
  • Web clients trust the JavaScript served by Konfidant. When you share or open a link in the browser, the encryption code is delivered by Konfidant, so a compromised or malicious deployment could in principle serve code that leaks keys. Download pages load no third-party scripts and send a strict Content Security Policy to reduce this risk. If this matters for your threat model, create shares with an official SDK, whose code you install and can review, and open them with the SDK’s openShare method.

Use an official SDK

The official SDKs implement KNF1, the upload flow and link handling, and are tested against the official KNF1 test vectors. We strongly recommend them over raw HTTP calls.

LanguagePackageShare textShare fileOpen a link
JavaScript/TypeScript@konfidant/sdkshareTextshareFileopenShare
Gogithub.com/konfidant/sdk-goShareTextShareFileOpenShare
Pythonkonfidantshare_textshare_fileopen_share
Rubykonfidantshare_textshare_fileopen_share
import { KonfidantClient } from '@konfidant/sdk';
const client = new KonfidantClient({ apiKey: process.env.KONFIDANT_API_KEY! });
const { shareUrl } = await client.shareText('db-password: hunter2', { ttlHours: 24 });
// https://download.konfidant.app/#t=<token>&k=<key>

Calling the API without an SDK

The API does not accept plaintext. A raw HTTP client must implement KNF1 itself:

  1. Generate a 32-byte key with a cryptographically secure random generator. Never reuse a key.
  2. Encrypt the content as KNF1 (below).
  3. Create the share:
    • Text: POST /api/v1/texts with {"ciphertext": "<standard base64 of the KNF1 bytes>", "ttl_hours": 24}.
    • File: follow Sharing an Encrypted File.
  4. Build the share link: download_url + "&k=" + base64url(key), where the key is unpadded base64url (43 characters). Send the key only in the fragment, never in a request body, query string or header.

ttl_hours is optional; when omitted, the share uses the maximum lifetime allowed by your plan.

To open a share link programmatically, take the origin of the link and send the token from the fragment:

curl --request POST 'https://download.konfidant.app/api/download' \
--header 'Content-Type: application/json' \
--data '{"t": "<token from #t=, URL-decoded>"}' \
--output share.knf1

The response is the KNF1 ciphertext (application/octet-stream), or 410 if the share was already opened or has expired. Opening a share consumes it. Decrypt the response locally with the key from &k=. No API key is needed.

The KNF1 format

KNF1 is a chunked AES-256-GCM format (the STREAM construction): every chunk has its own nonce, which prevents reordering, duplication and truncation of chunks.

ciphertext = header (16 bytes) || sealed_chunk_0 || sealed_chunk_1 || ... || sealed_chunk_n

The header is plaintext and is authenticated as additional data (AAD) of every chunk.

OffsetSizeFieldValue
04magicASCII KNF1 (4B 4E 46 31)
44chunk_sizeuint32 big-endian, plaintext bytes per chunk. Default 1 048 576; valid 4 096 to 16 777 216
87nonce_prefix7 random bytes, fresh per share
151reserved00

Plaintext stream

stream = uint32_be(len(meta)) || meta || content
meta = kind (1 byte) || uint16_be(len(name)) || name || uint16_be(len(mime)) || mime
  • kind: 01 for text, 02 for file.
  • name: UTF-8 file name, at most 1 024 bytes. Empty for text.
  • mime: UTF-8 MIME type, at most 255 bytes. Empty for text; may be empty for files.
  • content: the raw file bytes, or the UTF-8 text.

Chunks

Split stream into chunks of chunk_size bytes; the last chunk has 1 to chunk_size bytes. With N chunks, for chunk index i (0-based):

nonce_i = nonce_prefix (7) || uint32_be(i) (4) || last_flag (1) ; last_flag = 01 if i == N-1, else 00
sealed_chunk_i = AES-256-GCM-Encrypt(key, nonce_i, plaintext = chunk_i, aad = header) ; ciphertext || 16-byte tag

Size

S = 4 + len(meta) + len(content)
size = 16 + S + 16 * ceil(S / chunk_size)

For files, size is the ciphertext_size you declare in POST /api/v1/files. The plan’s file size limit is applied to the content; the server allows for the KNF1 overhead.

Decryption

  1. Check magic, reserved == 0 and that chunk_size is in range. Reject anything else.
  2. Read sealed chunks of chunk_size + 16 bytes; the final sealed chunk is the remainder (17 to chunk_size + 16 bytes) and is opened with last_flag = 01.
  3. Treat any authentication failure as fatal and discard all output: it means a wrong key, or modified, reordered or truncated data.
  4. Parse meta from the start of the decrypted stream and reject unknown kind values or inconsistent lengths. The remaining bytes are the content.

Test your implementation against the official KNF1 test vectors, which the official SDKs reproduce byte for byte. If you are not sure, use an SDK.