跳到正文
DEV Community· CokeBear·· 3 小時前AI 評分54

Clipboard Share 如何在瀏覽器以 Web Crypto 加密檔案:PBKDF2、HKDF 與 1 MiB AES-GCM 分塊

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

AI 導讀

Clipboard Share 加入可選檔案密碼鎖,讓用戶在瀏覽器端加密檔案。密碼經 PBKDF2-SHA256(600,000 次迭代)及 HKDF 派生加密金鑰與驗證值,每個檔案另用隨機金鑰,並將資料分成 1 MiB AES-GCM 區塊加密。下載時由 service worker 隨資料到達逐塊解密;密碼鎖不保護文字及裝置資訊,持有 QR 連結者亦可不輸入密碼下載該檔案。

正文

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.

What I wanted

  • Locking is something you switch on. The normal flow gets no extra clicks.
  • The password and the encryption keys are never sent to the server.
  • Big files still have to work. The design doc says up to 5 GB, so I couldn't encrypt a whole file in memory.

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.

The overall shape

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.

Step 1: one password, two keys

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:

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.

Step 2: a random key per file

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.

Step 3: 1 MiB records

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":

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:

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.

Step 4: decrypting while downloading

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.

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.

Step 5: attempt limits per password, not per file

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:

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).

Step 6: QR links with the key after the #

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.

What's still not good

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.

Wrap-up

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:

https://www.clipboardapp.org

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

Originally published in Korean on velog.

來源:DEV Community · dev.to