Locking files in the browser with Web Crypto: PBKDF2, HKDF, and 1 MiB AES-GCM records A solo developer built Clipboard Share, a same-network file-sharing web app, and added an opt-in password lock implemented entirely with the browser's Web Crypto API. The scheme derives a 256-bit encryption key and a separate verifier from the password via PBKDF2-SHA256 at 600,000 iterations, splits them with HKDF, wraps a random per-file key with AES-GCM, and encrypts file bodies in 1 MiB AES-GCM records decrypted by a service worker as bytes arrive. The server stores only ciphertext, the verifier's SHA-256 hash, the wrapped file key and an encrypted filename, so it never sees the password or encryption key. 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/