FLIT.

TECHNICAL

CRYPTOGRAPHY SPEC

Flit container · encVersion 1 · last updated August 5, 2026

Written so an independent implementation can be built and checked against ours. If you want the plain-language version, read /security instead.

01 · SCOPE

Applies to flits created by the Flit web app. The iOS and Android apps do not encrypt yet, and flits created before this release were not re-encrypted retroactively; all three are stored and served as plaintext, marked by a per-flit flag.

The server stores containers opaquely and holds no key at any point. It cannot decrypt an encrypted flit, and no code path asks it to, including the review process described under MODERATION.

02 · THREAT MODEL

Defends against:

  • Us. An operator with full database and object-storage access reads ciphertext and metadata only.
  • Our infrastructure providers, who hold strictly less than we do.
  • An attacker who can modify stored bytes: chunks cannot be reordered, duplicated, truncated, or spliced between containers without detection (see ADDITIONAL AUTHENTICATED DATA).
  • Rewriting a flit's filename or declared type. Both are bound into the container, so altering either makes it refuse to decrypt (see METADATA BINDING).

Does not defend against:

  • The recipient. They hold the key by design and can save, screenshot, or forward anything they can open.
  • Metadata and traffic analysis. Filenames, declared types, sizes, and timing are plaintext (see WHAT THE SERVER HOLDS IN PLAINTEXT).
  • A compromised endpoint. Whoever controls the device controls the keys stored on it.
  • A hostile declared MIME type given a scripting context on our own origin. The uploader still chooses that value; binding it stops anyone else changing it afterwards, which is a different problem. Mitigated, not eliminated, by RENDER POLICY.
  • A reporter who voluntarily discloses their key (see MODERATION).
  • A compromised or coerced web origin. Browser-delivered end-to-end encryption trusts the server to ship honest JavaScript on every single load. This is the weakest assumption on the page and we are not going to pretend otherwise; see NON-GOALS AND LIMITATIONS.
03 · KEYS

One 256-bit key per flit, generated on the creating client from the platform CSPRNG: crypto.subtle.generateKey on web. Keys are never derived from a password and never reused between flits.

Exported raw and encoded base64url without padding, giving 43 characters. Transported only in the URL fragment /f/<id>#k=<key>. A fragment is not sent in any HTTP request, so the key never reaches the server, our logs, or a Referer header.

Decoders require the canonical encoding and reject anything else. 43 characters carry 258 bits against a 256-bit key, so the final character has 2 bits that are not part of the key, and a permissive decoder discards them silently rather than refusing. That would give every key four distinct spellings that all decode and all produce the same commitment. Implementations should re-encode after decoding and compare, per RFC 4648 section 3.5.

Creating clients cache the key locally so they can rebuild the link later. That cache is device-local and is not attached to the account, which is why the same account on another device can list a flit but not open it.

payloadKey(i) := HKDF-SHA256(ikm = key, salt = "",
                             info = utf8("flit-item-key-v1") ‖ u32be(i), L = 32)
nameKey(i)    := HKDF-SHA256(ikm = key, salt = "",
                             info = utf8("flit-name-key-v1") ‖ u32be(i), L = 32)
thumbKey(i)   := HKDF-SHA256(ikm = key, salt = "",
                             info = utf8("flit-thumb-key-v1") ‖ u32be(i), L = 32)

A flit can hold several files. Each is sealed as its own container under subkeys derived from the flit key and that file's index, so the key material that opens the first file does not open the second. Note the payload key is derived, not the flit key used directly.

Without that binding nothing outside the database would tie a container to the position it occupies, and the database is written by the operator. Deriving from the index means a file moved between positions, substituted for another, or injected from elsewhere simply fails to open. The index is encoded as four big-endian bytes rather than decimal text, so two implementations cannot disagree about number formatting.

04 · CONTAINER
blob      := header ‖ chunk+ ‖ thumbSlot?         (always ≥ 1 chunk)
header    := preamble(40) ‖ nameSlot(268)         = 308 bytes
preamble  := magic(4) ‖ chunkSizeLog2(1) ‖ thumbLen(2) ‖ reserved(1) ‖ metaHash(32)
             magic     = "FLT1" = 46 4C 54 31
             thumbLen  = u16be, 0 when the item has no preview
             reserved  = 00
nameSlot  := nonce(12) ‖ ciphertext(240) ‖ tag(16)
chunk     := nonce(12) ‖ ciphertext ‖ tag(16)
thumbSlot := nonce(12) ‖ ciphertext(thumbLen) ‖ tag(16)   iff thumbLen > 0
  • Default chunkSizeLog2 is 20: 1 MiB of plaintext per chunk. The container self-describes its chunk size, so a decoder reads it from the header rather than assuming.
  • Accepted range is 10…26 inclusive. Values outside it are rejected before any allocation, so a hostile header cannot force a multi-gigabyte buffer.
  • reserved must be zero, and is enforced on read.
  • nameSlot holds the real filename, sealed under a separate key (see FILENAME CONFIDENTIALITY). Its size is fixed, so the header stays a constant 308 bytes and a reader can fetch it in a single range request.
  • thumbSlot holds a sender-generated preview, sealed under its own key and present only when thumbLenis non-zero. It is a trailer rather than a header field because the per-chunk AAD is the whole header, so a preview inside the header would be re-authenticated on every chunk. Its length sits in the preamble instead, which is already covered by that AAD — so the length is authenticated at no cost, and the trailer's offset is derivable without reading the payload.
  • thumbLenis bounded at 32768 by policy on both the writing and reading side, well under the field's own u16 range. Unlike the name slot it is variable-length and unpadded: a filename's length is informative and nothing else discloses it, whereas the item's byte size is already visible to the server.
  • A decoder must treat the chunk region as ending thumbSlot bytes before the end of the container, not at the end of the data. Reading to the end feeds the trailer to the final chunk, which then fails to authenticate.
  • Every chunk but the last carries exactly one full chunk of plaintext; the last carries between zero and one.
  • Empty plaintext still emits one chunk, so the final-chunk marker is always present and a zero-length container is never valid.
  • Containers written before the preview field existed set those bytes to zero, which reads as no preview. There is no second version.

Chunking exists because the Web Crypto API has no streaming AES-GCM. A single whole-payload seal forces both plaintext and ciphertext into memory at once, roughly three times the file in a browser tab. At the 1 GB per-link cap that loses the tab. Chunked, the format permits bounded memory in both directions, and the web implementation holds about one chunk on encrypt and on decrypt.

05 · AEAD PARAMETERS
  • AES-256-GCM (NIST SP 800-38D), one key per flit, applied per chunk.
  • 96-bit nonce, 12 fresh random bytes per chunk, stored inline as the chunk prefix. With a per-flit random key the birthday bound is around 2^48 chunks, unreachable under the platform size caps.
  • 128-bit authentication tag. This is the platform default on both Web Crypto and CryptoKit and neither implementation sets an explicit tag length. A reimplementation that truncates the tag will not interoperate.
06 · ADDITIONAL AUTHENTICATED DATA
aad := header(308) ‖ chunkIndex(8, big-endian u64) ‖ isFinal(1)   = 317 bytes

Each chunk is sealed under this AAD. Three bindings, each doing a distinct job:

  • Binding the header stops the chunk size being reinterpreted, which would otherwise let an attacker re-frame the payload.
  • Binding the index stops chunks being reordered or duplicated.
  • Binding the final flag stops the payload being truncated at a chunk boundary.

Without this, every chunk would still authenticate individually while the sequence stayed malleable. That is the failure mode any chunked AEAD format has to answer for. Our conformance suite tests each of these attacks explicitly.

07 · FILENAME CONFIDENTIALITY
nameKey   := HKDF-SHA256(ikm = key, salt = "",
                          info = utf8("flit-name-key-v1") ‖ u32be(itemIndex), L = 32)
plaintext := utf8(filename) zero-padded to exactly 240 bytes
nameSlot  := nonce(12) ‖ AES-256-GCM(nameKey, plaintext, aad = preamble(40)) ‖ tag(16)

The filename a sender picks is often more revealing than the file it names. It is the one metadata field with high information content and no server-side use, so it is not stored. What the server receives and keeps is an extension-only placeholder: file.pdf, or bare file when there is no usable extension.

The extension survives because things genuinely depend on it. The upload blocklist screens on it, the render policy in RENDER POLICY keys off it, and the signed-URL lifetime is chosen by it. The name does not, so it goes in the container.

  • The slot is a fixed 240 bytes of capacity, zero-padded. A variable-length field would leak the name's length through the stored object size; a constant one leaks nothing at all.
  • Longer names are truncated on a codepoint boundary, never mid-sequence. Filesystems cap around 255 bytes, so this is not reachable in normal use.
  • The subkey is derived rather than reused. Sealing the slot under the payload key would be sound here, given the distinct nonce and the distinct AAD, but separating keys by purpose is cheap and removes the question.
  • The AAD is the 40-byte preamble, so the name is bound to the chunk framing and to METADATA BINDING. The whole header, slot included, is in turn bound into every payload chunk (ADDITIONAL AUTHENTICATED DATA), so the name cannot be swapped between containers.
  • The server enforces the placeholder rather than trusting clients to send it. An encrypted upload declaring anything else is rejected at init, so a modified client cannot quietly reinstate the disclosure.
  • Message and link flits have no filename. The slot is still present, still sealed, and still padded, so their containers are indistinguishable in shape from a file's.

One consequence worth stating plainly. The name is recoverable only with the key, so the dashboard on a device that did not create a flit shows the placeholder. That is the same device scoping the key itself has, for the same reason: we hold nothing to restore it from.

08 · METADATA BINDING
metaHash := SHA-256(
     utf8("flit-meta-v1")
   ‖ u32be(itemIndex) ‖ u32be(itemCount)
   ‖ u32be(byteLength(utf8(originalFileName))) ‖ utf8(originalFileName)
   ‖ u32be(byteLength(utf8(mimeType)))         ‖ utf8(mimeType)
 )

A flit's filename and declared type are stored in plaintext, because quota accounting, the extension blocklist and the deletion sweeps all read them (see WHAT THE SERVER HOLDS IN PLAINTEXT). The filename here is the placeholder, not the real one (see FILENAME CONFIDENTIALITY). Authenticating them is what stops anyone able to write to the database relabelling a file without a client noticing. The threat model names the operator first, so that gap was ours to close.

The hash also covers the file's index within its flit and the total number of files in it. The index alone would stop reordering, substitution and injection. The count is what additionally catches truncation: with only an index, a flit that had one of its files removed would leave every remaining file opening perfectly, and no client could tell anything was missing.

Both terms sit here rather than in a separate structure because the hash is already inside the preamble and the preamble is already inside every chunk's AAD. Binding a file's position therefore costs nothing per chunk.

The hash goes in the header, and the header is already bound into every chunk's AAD (see ADDITIONAL AUTHENTICATED DATA). So extending the header binds the metadata with no new AAD structure and no per-chunk cost, and a mismatch fails authentication on the very first chunk. Decoders also compare the hash explicitly before touching any chunk, purely so the failure is reportable as “this does not match” rather than being indistinguishable from corruption.

  • Both fields are length-prefixed, so a filename and a type cannot be shifted across the boundary between them while keeping the same digest.
  • Message and link flits have neither field. Both are the empty string, giving a fixed constant, rather than the format carrying a special case.
  • The values hashed are exactly the ones declared at upload, before any normalization. RENDER POLICY transforms the type for display; that transformed value is never what gets hashed.
  • The server verifies this at upload too. It holds both fields in plaintext already, so it can recompute the digest and reject a mismatch once, at commit, instead of leaving it to every future reader. That is a correctness check, not a defence: a server cannot meaningfully audit itself.
09 · SIZE
encryptedSize(n, t) = 308 + n + 28 × max(1, ceil(n / chunkSize))
                      + (t > 0 ? 28 + t : 0)

  n = 0            →             336 bytes
  n = 1 MiB        →       1,048,912 bytes
  n = 5,000,000    →       5,000,448 bytes
  n = 250,000,000  →     250,007,000 bytes   (239 chunks)

  t = sealed preview length, 0 ≤ t ≤ 32768

Steady-state overhead is about 0.0027%; the 308-byte header is fixed, not per chunk. The figure is exact and computable in advance, which matters because the presigned upload signs a Content-Length. The client has to declare the ciphertext size before producing a single byte of it.

10 · KEY COMMITMENT
keyCommitment := base64url( SHA-256( utf8("flit-key-proof-v1") ‖ rawKey[32] ) )

Stored with the flit at creation. Every non-owner request that returns content presents the same value in an X-Key-Proof header. That covers both the read and the download of the stored object; a mismatch returns 403 KEY_REQUIRED and spends no view.

It answers a different problem on each path. On the read: consuming a view is a destructive act, and the server cannot distinguish a genuine recipient from someone who appended junk after #k=, because it never sees the key. Without a commitment, anyone holding a flit id could drain its views one request at a time, and at a limit of one view destroy the content. On the download: confidentiality never depended on it (the container is undecryptable without the fragment), but ephemerality did. Ciphertext anyone could pull today and keep becomes readable the day that key leaks, which is exactly what an expiring share is supposed to prevent.

The one deliberate exemption is the metadata probe, which runs the same expiry and blocking gates but returns no content and counts no view. It has to stay readable without a key so a viewer whose link arrived truncated can be told the link is incomplete without spending the flit's only view to find out.

It does not weaken the encryption: the key is 256 random bits, so the commitment is not invertible and the server still cannot decrypt. It is deliberately a bearer value for view accounting: holding it lets you spend views, never read content. That is why it is domain-separated from the raw key rather than being a bare hash of it.

Enforced only where a commitment exists, so plaintext flits and anything created before this shipped are unaffected. Owners are exempt, because an owner's own read never counted a view.

11 · WHAT THE SERVER HOLDS IN PLAINTEXT

Deliberately unencrypted, because quota enforcement, the file-type blocklist, expiry, and the deletion sweeps all run server-side.

Read this as what is visible to anyone holding a link, not only to us. None of it requires the key, and the metadata probe described in KEY COMMITMENT returns part of it without one. Treat a link as disclosing this list:

  • An extension-only placeholder filename — file.pdf, never the name the sender chose. The real one is sealed in the container; see FILENAME CONFIDENTIALITY.
  • Declared MIME type, and stored size (the ciphertext length, not the plaintext length).
  • Content class: message, link, file, or media.
  • Creation time, expiry time, view limit, view count, and last-accessed time.
  • The anonymous device identifier that created the flit, or the account id if the creator was signed in.
  • The creator's IP address, deleted on the same lifecycle as the content it was captured with.
  • For view-limited flits, one record per viewer: the viewer's device or account identifier and the time of first view.
  • The key commitment, derived as KEY COMMITMENT describes.
  • Whether an item carries a preview, and how many bytes that preview occupies. The preview itself is sealed and unreadable without the key; only thumbLen is in the clear, and it is a byte count on an item whose total size is already listed above.

The encrypted payload is the only thing that is opaque: the whole container for a file or media flit, and a base64 encoding of it for a message or link. The sealed preview is opaque on the same terms — the server can hand it to a viewer, and cannot open it.

12 · RENDER POLICY

The declared MIME type is chosen by the creating client and stored verbatim. For encrypted uploads it is never corrected, because the commit-time magic-byte sniff cannot run on ciphertext. It is therefore unverified for exactly the flits the viewer decrypts itself.

That matters because the decrypted bytes end up in a blob:URL, and a blob URL inherits the creating page's origin and its CSP. An unrestricted type would therefore be same-origin script execution on our own domain. Both the API and the viewer independently gate it through the same allowlist:

render-safe := image/*   (except image/svg+xml and image/svg)
             | video/*
             | audio/*
             | application/pdf
             | text/plain

everything else → application/octet-stream

SVG is excluded despite being an image, because it is a scriptable document and both render paths end in a URL a viewer can navigate to. Nothing is blocked from being shared. A non-allowlisted type simply downloads instead of being given a rendering context.

13 · MODERATION

Content review is reactive only. There is no proactive scanning, because there is nothing scannable. The server cannot read an encrypted flit.

A report carries the key from the link the reporter already holds. The server still never decrypts: the admin console decrypts in the reviewer's browser using that disclosed key. Reported file and media objects are pinned against automatic deletion until a reviewer resolves the report, and reported message and link ciphertext is snapshotted onto the report for the same reason.

Disclosure is per-flit and reporter-initiated. It is not a master key, it grants no access to any other flit, and it is not retained beyond the report record, which is deleted after twelve months.

Reporting requires the same proof of key possession that reading does (see KEY COMMITMENT). A report outlives the expiry the creator chose, so it is not something a passer-by who only has a flit's identifier gets to trigger.

The pin on reported file and media objects expires after 30 days even if the report is still open. Otherwise an unreviewed report would keep content alive for the full twelve months of the report record, which is not a promise an expiring share should quietly break. Message and link evidence is unaffected: it is snapshotted onto the report rather than pinned in place.

14 · TEST VECTORS

/security/vectors.json holds 16 containers with their keys and expected plaintexts. This is the same file both test suites consume, not a copy of it: flit-web's verify:crypto and flit-ios's FlitCryptoTests run against these exact bytes, and a build check asserts the published form matches byte for byte.

sha256  190503aa78d734f6739e52fe5a849442291ffe61ddd148a56945254523dd0b7b

Conformance

An implementation is correct if, for every entry, it decrypts containerBase64 under keyFragment to exactly plaintextBase64, and derives keyProof from keyFragment per KEY COMMITMENT. Each entry carries the originalFileName and mimeType it was sealed against (see METADATA BINDING); decrypting one under any other metadata must fail. Entries use small chunk sizes deliberately, so multi-chunk framing is covered by kilobyte payloads:

  • empty, 1 KiB chunks, text metadata
  • single byte, 1 KiB chunks, text metadata
  • one byte under a chunk
  • exactly one chunk
  • one byte over a chunk
  • three chunks plus short tail
  • two chunks, 2 KiB chunks
  • non-ASCII sealed filename, 2 KiB chunks
  • sealed filename at the slot boundary
  • 3-item flit, item 0 of 3
  • 3-item flit, item 1 of 3, straddles a chunk boundary
  • 3-item flit, item 2 of 3, final item
  • photo with a sealed thumbnail, partial final chunk
  • photo with a sealed thumbnail, exactly full final chunk
  • 2-item flit with thumbnails, item 0 of 2
  • 2-item flit with thumbnails, item 1 of 2, straddles a chunk boundary
curl -s https://getflit.app/security/vectors.json | shasum -a 256
15 · NON-GOALS AND LIMITATIONS
  • Browser-delivered encryption trusts the origin on every load. We serve the JavaScript that does the encrypting, so a compromised or legally coerced deployment could serve code that leaks keys, and a viewer would not notice. A signed native binary, reviewed and distributed by a third party, is a materially stronger trust story than a web page. We are not claiming the two are equivalent, and no web application can honestly claim otherwise.
  • No forward secrecy. One static key per flit, for the life of that flit. Compromising the key compromises the flit, but only that flit.
  • No sender authentication. The container proves integrity, not authorship. Anyone holding the key could have produced it; nothing binds a flit to an identity.
  • No length hiding. Ciphertext length is plaintext length plus a fixed, published overhead, so the approximate size of anything shared is observable. There is no padding.
  • No key rotation.A flit's key cannot be changed or revoked independently of deleting the flit.
  • Seeking encrypted media depends on the service worker. The web viewer registers a first-party service worker that decrypts chunks on demand and answers range requests, so playback starts immediately and seeking works without downloading the whole file. It stores nothing: responses are marked not to be cached, and the key is held in memory for the life of the view. Where the worker is unavailable, on a browser that does not support it or in a page served without a secure context, the viewer falls back to fetching the whole container and decrypting it in one pass, and seeking is then not possible until the download has finished.
16 · CHANGES AND CONTACT

encVersion 1 is the initial format. A format change bumps encVersion and republishes the vectors above; existing flits keep the version they were created with, and every published version stays decodable. Re-sealing an old container would need its key, which we have never had.

The sealed preview and its length field were added within encVersion 1 rather than as a new version. The length occupies two bytes the format already required to be zero and rejected otherwise, so a container written before the addition decodes as carrying no preview — self-describing, not ambiguous. Nothing that decoded before decodes differently now.

Errors in this document, or in what it describes: security@getflit.app. Disclosure terms are on /security.