# Locking files in the browser with Web Crypto: PBKDF2, HKDF, and 1 MiB AES-GCM records

> Source: <https://dev.to/kangchanghwan/locking-files-in-the-browser-with-web-crypto-pbkdf2-hkdf-and-1-mib-aes-gcm-records-4d50>
> Published: 2026-10-21 23:07:11+00:00

I built a small web app called Clipboard Share. Devices on the same Wi-Fi network see each other automatically, no sign-up, and you drag a file or paste some text to send it. It was made with Claude Code, and I'm the only person working on it.

The thing about "same network, no account" is that anyone on that network can join the room. That's fine for a meme or a photo. It's less fine for a scan of an ID card or a contract that you want to move to a shared PC.

So I added an opt-in "lock with a password" switch. This post is about how it works, with the real code, and a list of what it still doesn't do. Locking is off by default, and you turn it on per upload.

The last point shaped everything else. It led to chunked encryption, a service worker on the download side, and a few details I would have skipped otherwise.

Password → PBKDF2 → HKDF splits it into `encKey` and `verifier` → a random per-file key is wrapped with `encKey` and stored in metadata → the file body is encrypted in 1 MiB AES-GCM records → on download, a service worker decrypts while the bytes arrive.

The server stores ciphertext, the hash of the verifier, the wrapped file key, and the encrypted file name. It never receives the password or `encKey`.

The password goes through PBKDF2-SHA256 with 600,000 iterations (`PBKDF2_ITERATIONS = 600000`). The salt is 16 random bytes, generated once each time you set up a lock. Within one session I reuse the derived keys, because running 600,000 iterations per file would be painfully slow.

There's a design problem here. The server has to check that the password is right, so the client has to send something. That something must not be the encryption key. So I split the PBKDF2 output with HKDF into two independent values, using different `info` strings:

``` js
const hkdf = await subtle().importKey('raw', masterBytes, 'HKDF', false, ['deriveBits', 'deriveKey'])
const empty = new Uint8Array(0)
const encKey = await subtle().deriveKey(
  { name: 'HKDF', hash: 'SHA-256', salt: empty, info: INFO_ENC },
  hkdf,
  { name: 'AES-GCM', length: 256 },
  false,
  ['encrypt', 'decrypt']
)
const verifierBits = await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: empty, info: INFO_VERIFY }, hkdf, 256)
const vh = toHex(await subtle().digest('SHA-256', verifierBits))
```

That's `deriveLockFromMaster` in `utils/lockCrypto.js`. The info strings are `cs-lock-v1/enc` and `cs-lock-v1/verify`. `encKey` is created with `extractable: false`, so page JavaScript can use it but can't read the raw bytes.

The server stores only `vh`, the SHA-256 of the verifier. To unlock, the client sends the verifier and the server hashes it and compares. So the server can recognize the right password, but it can't compute `encKey` from what it holds.

I don't encrypt file contents with `encKey` directly. Each file gets its own random 32-byte key (the DK). The DK encrypts the body and the file name. `encKey` only wraps the DK.

`wrapFileKey` encrypts the DK with AES-GCM under a random 12-byte IV and the additional data `cs-lock-v1/key`. The result is 48 bytes (32 + the 16-byte tag), which goes into the object metadata along with the IV.

Two things come out of this. Files locked with the same password still have different keys. And the QR link later on can carry the key for one file only, instead of anything password-wide.

The file name is encrypted with the DK too, trimmed to 180 UTF-8 bytes without cutting a character in half and keeping the extension. In the UI, a locked file's card shows random characters instead of its name until it's unlocked.

The uploading device also creates a random 32-byte delete key. Only its SHA-256 (`dh`) goes to the server. The key itself stays in that device's memory.

AES-GCM only gives you the plaintext after it has verified the whole message. If I encrypted a 2 GB file as one message, the browser would have to hold all of it, and the receiver couldn't release a single byte until the end.

So the plaintext is cut into 1 MiB records (`RECORD_SIZE = 1024 * 1024`), and each record is its own AES-GCM message with a 16-byte tag. The IV is a random 4-byte per-file prefix plus the record index as a 64-bit big-endian number. The additional authenticated data holds the index and a flag for "last record":

``` js
export function recordAad(index, isLast) {
  const aad = new Uint8Array(AAD_RECORD_PREFIX.length + 9)
  aad.set(AAD_RECORD_PREFIX, 0)
  const view = new DataView(aad.buffer)
  writeU64(view, AAD_RECORD_PREFIX.length, index)
  aad[aad.length - 1] = isLast ? 1 : 0
  return aad
}
```

The last-record flag matters. With independently authenticated records, chopping the end off a file leaves every earlier record valid, so a truncated file would decrypt "successfully" into a shorter one. With the flag in the AAD, the new final record fails authentication because it wasn't encrypted as the last one. The decryptor's `finish()` also checks that exactly the expected number of records arrived (`LOCK_TRUNCATED`, `LOCK_TRAILING_DATA`).

Uploading reuses the existing multipart uploader. The server is told it's receiving a "virtual file" the size of the ciphertext. One part is 64 records, which is `64 × (1 MiB + 16)` bytes of ciphertext. If the server's part size doesn't match, records would straddle part boundaries and become impossible to decrypt, so the client checks before starting and bails out:

``` js
const created = await baseApi.create(rid, { size: pt, lock: meta })
if (created.partSize !== CT_PART_SIZE) {
  // 파트 경계가 다르면 레코드가 파트 사이에 걸쳐 풀 수 없게 된다 → 시작하지 않는다
  try { await baseApi.abort(rid, { uploadId: created.uploadId, key: created.key, size: total }) } catch { /* ignore */ }
  throw Object.assign(new Error('Unexpected part size'), { code: 'LOCK_PART_SIZE', retryable: false })
}
```

(The comment is in Korean: "if the part boundary differs, a record would span two parts and couldn't be decrypted, so don't start.")

Each part is encrypted just before it's uploaded, reading only that slice of the file (`encryptPart`), so the full file never sits in memory. One limitation: locked uploads don't save resume state to localStorage. Retries during a session work, but you can't close the tab and resume later.

Downloading a multi-gigabyte ciphertext and decrypting afterwards would crash a phone. Instead, a service worker intercepts requests to `/__locked/<token>`, decrypts records as the bytes arrive, and streams the plaintext to the browser, which treats it as a normal download.

``` js
const body = new ReadableStream({
  async pull(controller) {
    for (;;) {
      const { done, value } = await reader.read()
      if (done) {
        decryptor.finish()
        controller.close()
        return
      }
      const out = await decryptor.push(value)
      if (out.length > 0) {
        for (const pt of out) controller.enqueue(pt)
        return
      }
    }
  }
})
```

That's trimmed from `handleLockedDownload` in `public/sw.js`; I removed progress notifications and error handling. The decryptor accepts chunks of any size, buffers them, and returns plaintext whenever a full record is available.

Service workers aren't bundled in this project, so `sw.js` can't import `lockCrypto.js`. The decryption logic exists twice. It's ugly, but a test checks the worker's copy too.

The key goes to the worker through a `MessageChannel` as a one-time job with a random token. Jobs expire after two minutes (`LOCKED_JOB_TTL_MS`) and are deleted the first time they're used.

When there's no active service worker controller, or the device is iOS, the app falls back to in-memory decryption. That path refuses files over 500 MB (`MEMORY_DECRYPT_LIMIT`) and tells the user to download from another device. I haven't tested large files on real devices well enough to quote a real-world ceiling, so I'm not going to.

A password is only as strong as the effort needed to guess it, so wrong guesses are rate limited on the server. My first thought was per file, but then N files locked with the same password would give an attacker N times the attempts. So the counter is keyed by `vh`, one bucket for everything locked with that password:

``` js
export const UNLOCK_MAX_TOTAL = 30;
export const UNLOCK_MAX_PER_IP = 5;

export function unlockRemaining(vh: string, ipKey: string, now: number = Date.now()): number {
    const e = entries.get(vh);
    if (!e || now - e.at > ENTRY_TTL_MS) return Math.min(UNLOCK_MAX_TOTAL, UNLOCK_MAX_PER_IP);
    const ip = e.perIp.get(ipKey) ?? 0;
    return Math.max(0, Math.min(UNLOCK_MAX_TOTAL - e.total, UNLOCK_MAX_PER_IP - ip));
}
```

There are 30 wrong guesses total and 5 per IP, whichever runs out first.

The delete endpoint shares the same counter. If deleting accepted a verifier without limits, it would be an unlimited password-guessing oracle, which would make the limit pointless.

Deleting requires proof of one of two things: the delete key from the uploading device (hashed and compared with the stored `dh`), or the password verifier. Neither means a 403. Comparisons use `timingSafeEqual`. The unlock endpoint only hands out a presigned download URL after the verifier matches, and that URL lives for 60 seconds (`LOCKED_DOWNLOAD_EXPIRES_SEC`). Locked objects also sit in a separate R2 bucket (` R2_LOCKED_BUCKET_NAME`).

`#`
Typing a password on a phone is annoying, so every locked file has a "share by QR code" action. The link looks like this:

```
{origin}/#/lk?r={roomId}&i={id}&v={verifier}&k={file key}
```

Everything is after the `#`, so it isn't sent in HTTP requests to the server or CDN. `k` is the DK of that one file. The password and `encKey` are not in the link. `v` is the verifier the server needs before it gives out a download URL.

```
export function buildLockedLink(origin, { roomId, id, verifier, key }) {
  const q = new URLSearchParams({ r: roomId, i: id, v: verifier, k: key })
  return `${origin}/${LOCKED_LINK_PREFIX}${q.toString()}`
}
```

`index.html` strips the fragment from the address bar before the analytics script runs and keeps it only in `window.__csLockedLink`. When the link is opened, the app uses `k` to decrypt the file name; if that fails, it reports `LOCK_LINK_INVALID`.

Anyone who has the link or a photo of the QR code can download that file without the password. You can only create one if you locked the file yourself or confirmed the password in this session.

I'd rather list the limits than have somebody find them.

**1. The password lock doesn't cover text or device info.** It protects files and file names. Text you send, the animal-name labels for devices, file sizes and upload times are all visible. Text locking was pushed to a second phase in the design doc.

**2. Browser crypto means trusting the JavaScript the server sends.** The encryption runs in your browser, but the site delivers the code. If the site's code were changed, the browser-side encryption wouldn't help. Every web app that does crypto in the page has this limit. The accurate summary for files you lock with a password is: if you received the genuine code, the password isn't sent to the server. It's not "the server can never see anything."

**3. The verifier is per password, not per file.** The `v` in a QR link comes from the password. So with the same password on several files in one room, someone holding one QR link can also ask the server to download or delete the other files' ciphertext. They still can't decrypt those files, because `k` is per file, but the delete request is a real gap.

**4. Attempt counters live in server memory.** It's a `Map`. Restart the server and the counters reset. The design doc lists this as a known limit, and I haven't moved it to persistent storage. Related: the same doc notes that whoever holds the server's `vh` and a ciphertext could try short passwords offline. PBKDF2 at 600,000 iterations slows that down but doesn't remove it. The input currently only requires 4 characters (`MIN_LOCK_PASSWORD = 4`).

And there's no recovery. If you forget the password, the file stays locked. Locked files are kept like regular ones: they're deleted soon after everyone leaves, and at most about a day.

PBKDF2 at 600,000 iterations, HKDF to separate `encKey` and verifier, a wrapped random key per file, 1 MiB AES-GCM records with a last-record flag, a service worker that decrypts as bytes arrive, and attempt limits counted per password. If you spot something wrong, especially in the crypto, please tell me.

You can try it here:

Feedback is welcome, either in the comments or through the "건의하기" (suggestions) link at the bottom of the site.

*Originally published in Korean on [velog](https://velog.io/@ch_kang/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%ED%8C%8C%EC%9D%BC-%EC%95%94%ED%98%B8%ED%99%94-Web-Crypto-AES-GCM%EC%9C%BC%EB%A1%9C-%ED%8C%8C%EC%9D%BC-%EC%9E%A0%EA%B7%B8%EA%B8%B0-%EC%98%A8%EB%9D%BC%EC%9D%B8-%ED%81%B4%EB%A6%BD%EB%B3%B4%EB%93%9C-%EA%B0%9C%EB%B0%9C%EA%B8%B0).*
