skip to content
all writeups
4 min read

Flinger: how the URL fragment earns its keep

How Flinger uses the URL fragment, AES-256-GCM, and an HMAC proof token to build a file sharer where the server structurally cannot read what it stores.

webcryptographyinfranotes

The # in a share URL is load-bearing. Not decoration – the entire trust model depends on browsers never sending it to a server.

When you upload a file to Flinger, the browser generates a 256-bit AES-GCM key, encrypts every byte of your file before it leaves the device, and returns you a link like:

https://flinger.pro/s/abc123xyz#eW91cl9rZXlfaGVyZQ

That base64url blob after the # is the raw key. HTTP requests don’t include the fragment – RFC 3986 is clear on this, and every browser honours it. Cloudflare never sees it. The Node server never sees it. The only place the key exists is in the URL itself, in the browser tab of whoever has the link.

The server stores ciphertext. That’s it.

What “zero knowledge” actually requires

A lot of “encrypted” services encrypt at rest but hold the keys themselves. That’s better than plaintext, but it’s not zero knowledge – a subpoena, a breach, or a rogue employee can still get to your files. The constraint Flinger is built around is stricter: the server must be structurally incapable of decrypting, not just policy-prevented from doing so.

AES-256-GCM in the browser, key in the fragment, gets you most of the way there. But there’s a subtler problem: what about file names and MIME types? If those travel in plaintext, the server (or a malicious actor) learns a lot even without the content. So Flinger encrypts those too. Every encryptedName, encryptedMimeType, and encryptedDisplayName is AES-GCM ciphertext encrypted with the same key, serialised as {iv_base64url}.{ciphertext_base64url}:

src/lib/crypto/share.ts
export async function encryptStringValue(key: CryptoKey, value: string): Promise<string> {
	const bytes = new TextEncoder().encode(value);
	const chunk = await encryptChunk(key, bytes);
	return `${chunk.iv}.${chunk.ciphertext}`;
}

The server stores nothing human-readable. File sizes and chunk counts stay plaintext because the server needs them for rate limiting and reassembly – those are the deliberate leaks, and I think they’re unavoidable with my approach.

The proof-token problem

Encrypting metadata creates a new problem. The download page needs the encrypted names and MIME types before it can show you anything. But if the /api/download/meta endpoint just handed that out to anyone who asked with a valid shortId, you’ve got unauthenticated metadata enumeration – someone can probe every shortId and harvest ciphertexts, even if they can’t decrypt them.

The fix is a proof token. Before uploading, the browser derives one from the key:

src/lib/crypto/share.ts
export async function deriveProofToken(serializedKey: string): Promise<string> {
	const rawKey = base64UrlToBytes(serializedKey);
	const hmacKey = await crypto.subtle.importKey(
		'raw',
		rawKey,
		{ name: 'HMAC', hash: 'SHA-256' },
		false,
		['sign']
	);
	const label = new TextEncoder().encode('flinger-proof-v1');
	const signature = await crypto.subtle.sign('HMAC', hmacKey, label);
	return bytesToBase64Url(new Uint8Array(signature));
}

HMAC-SHA256(key_raw_bytes, "flinger-proof-v1") – the token is a commitment to the key without being the key. The uploader sends it to /api/upload/init, the server stores it alongside the share. On download, the browser re-derives the same token from the URL fragment and presents it to /api/download/meta. The server checks with timingSafeEqual. Match means you have the key; no match means you’re guessing.

The server still never learns the key itself. It only holds a MAC value it can verify but not invert. Someone who compromises the database gets ciphertexts and proof tokens but no keys.

Compression before encryption

Files get compressed client-side before the encrypt step – Brotli via a lazily-loaded WASM worker, with fflate (gzip) as fallback. The compression method is stored per-file in share metadata, also encrypted, so the download side knows how to decompress after decryption.

The “try and compare” part is worth calling out: the client compresses every file and only keeps the compressed version if it’s actually smaller. Already-compressed formats (JPEG, ZIP, MP4) don’t get the treatment. You’d think this would be obvious, but naive implementations skip the check and happily upload a larger file.

What the server actually stores

After a completed upload, the Share document in MongoDB looks like this in practice:

shortId:              "abc123xyz"        ← 10-char nanoid, collision-resistant
proofToken:           "<base64url MAC>"  ← verifiable, not invertible
encryptedDisplayName: "<iv.ciphertext>"  ← AES-GCM, null if not set
files[0].encryptedName:     "<iv.ciphertext>"
files[0].encryptedMimeType: "<iv.ciphertext>"
files[0].size:              1048576      ← plaintext: needed for limits
files[0].chunkCount:        1            ← plaintext: needed for upload tracking

R2 stores binary blobs at shares/{shareId}/{fileIndex}/{chunkIndex}.bin. Each blob is [IV (12 bytes)][AES-GCM ciphertext][GCM auth tag (16 bytes)]. No file name, no extension, no MIME type, no metadata at the object level.

The fingerprint abuse layer – hashed IP XOR’d with browser signals – is also worth a sentence. Flinger enforces per-day upload limits and storage caps per fingerprint, but it never stores a raw IP. SHA-256(clientIP) goes in; the IP itself doesn’t.

What this design doesn’t protect against

The honest part: Flinger’s security model has one hard assumption. The share URL must stay secret. The fragment being excluded from HTTP requests means the server can’t see it – but it doesn’t mean the URL is safe in your browser history, in a Slack message, in a screenshot. If you share the URL, you’ve shared the key. There’s no way around this given the design, and the product doesn’t try to pretend otherwise.

The model also has no post-share revocation by key rotation. If a share gets forwarded somewhere you didn’t intend, the recourse is download caps and expiry, not revoking the key. Proper key revocation would require the server to gate access in a way that requires knowing the user, which requires accounts, which is Phase 4 territory.

The fragment-as-key approach trades some security properties for others. No accounts, no server-side key storage, no “reset your password and re-encrypt all your files” problem. The tradeoff is deliberate.

Projects

what this writeup is about