Security & A11yWebCrypto APICloudflare Workers

Server-Blind Sync Layer

Building a zero-knowledge, end-to-end encrypted note sync relay using client-side key derivations and serverless database storage.

The Architectural Problem

When sync layers are designed, developers typically store credentials (passwords, auth sessions) and decryptable data directly in a centralized database. This exposes user notes to breaches and data snooping.

To address this on Note It Down, we built a zero-knowledge boundary: a serverless relay where your sync token and encryption keys never leave the browser. The database (Cloudflare KV) only stores encrypted bytes, and the edge worker verifies write requests without knowing the underlying raw credentials.

Sync Sequence Flow

Step-by-step key derivation, decryption, merging, and compare-and-swap write validation

sequenceDiagram autonumber participant UI as Extension UI participant Sync as sync.ts (Orchestrator) participant Crypto as crypto.ts (WebCrypto) participant Storage as storage.ts (Chrome Local) participant Merge as merge.ts (Resolution) participant Worker as Cloudflare Worker & KV UI->>Sync: User clicks 'Save & Sync' activate Sync Sync->>Crypto: Derive address, encryptionKey, verifyKey, & writeToken Crypto-->>Sync: Keys returned (derived via HKDF & HMAC-SHA256) Sync->>Worker: GET /v1/:address (Retrieve remote database) alt Remote data exists Worker-->>Sync: 200 OK with { blob, blobHash } Sync->>Crypto: Decrypt remote blob using encryptionKey Crypto-->>Sync: Decrypted remote notes (JSON) else First-time sync (no remote data) Worker-->>Sync: 404 Not Found Sync->>Sync: Set isFirstWrite = true end Sync->>Storage: Retrieve current local notes Storage-->>Sync: Return local notes array Sync->>Merge: mergeNotes(local, remote) Note over Merge: Deterministically merges notes by version/updatedAt (creates conflict copies on divergence). Merge-->>Sync: Merged notes array returned Sync->>Storage: Save merged notes back to browser storage Sync->>Crypto: Encrypt merged notes using encryptionKey (AES-GCM-256) Crypto-->>Sync: Return new Encrypted Blob Sync->>Worker: PUT /v1/:address { blob, writeToken, verifyKey?, previousHash } Note over Worker: Worker validates writeToken against verifyKey and performs a Compare-and-Swap (CAS) check. alt 409 Conflict (Remote was updated during sync) Worker-->>Sync: 409 Conflict Note over Sync: Sync conflict detected! Start retry loop (Max 2 attempts) Sync->>Worker: GET /v1/:address (Fetch fresh remote) Worker-->>Sync: 200 OK with new { blob, blobHash } Sync->>Crypto: Decrypt new remote blob Sync->>Merge: Re-merge local database with new remote database Sync->>Storage: Save re-merged notes Sync->>Crypto: Encrypt new merged database Sync->>Worker: PUT /v1/:address { new blob, writeToken, previousHash: newHash } end Worker-->>Sync: 200 OK (Write Successful) deactivate Sync Sync-->>UI: Return SyncResult (success/syncedAt)

1. Key Derivation Pipeline (HKDF)

To prevent key reuse (using the same key for authentication and encryption), we run your raw sync token through **HKDF-SHA-256** (HMAC-based Extract-and-Expand Key Derivation Function). This derives three separate keys with distinct domain tags:

1. Lookup Address

Derived using the tag `"nid-db-address"`. Since HKDF is deterministic, the same sync token always generates the same 32-byte address, acting as the database lookup row identifier.

2. Encryption Key

Derived using `"nid-encryption-key"`. Creates a unique 256-bit AES key used on-device to encrypt note arrays using the AES-GCM authenticated cipher.

3. Verification Key

Derived using `"nid-verification-key"`. Transmitted to the Cloudflare Worker *only on the first database write* to authenticate future write attempts.

2. Client-Side WebCrypto Implementation

Below is the TypeScript code running in the Chrome Extension background context to derive these cryptographic components using the native WebCrypto API:

async function deriveSyncKeys(rawToken: string) {
  const encoder = new TextEncoder();
  const tokenBytes = encoder.encode(rawToken);

  // Import raw token bytes into an HKDF-capable CryptoKey object
  const baseKey = await crypto.subtle.importKey(
    "raw",
    tokenBytes,
    { name: "HKDF" },
    false,
    ["deriveBits", "deriveKey"]
  );

  // 1. Derive lookup address (32 bytes)
  const addressBuffer = await crypto.subtle.deriveBits(
    {
      name: "HKDF",
      hash: "SHA-256",
      salt: new Uint8Array(32), // 32-byte zero salt
      info: encoder.encode("nid-db-address")
    },
    baseKey,
    256 // 32 bytes * 8 bits
  );
  const address = arrayBufferToHex(addressBuffer);

  // 2. Derive AES-GCM Encryption Key
  const encryptionKey = await crypto.subtle.deriveKey(
    {
      name: "HKDF",
      hash: "SHA-256",
      salt: new Uint8Array(32),
      info: encoder.encode("nid-encryption-key")
    },
    baseKey,
    { name: "AES-GCM", length: 256 },
    false,
    ["encrypt", "decrypt"]
  );

  // 3. Derive Verification Key (used for HMAC write token generation)
  const verifyKey = await crypto.subtle.deriveKey(
    {
      name: "HKDF",
      hash: "SHA-256",
      salt: new Uint8Array(32),
      info: encoder.encode("nid-verification-key")
    },
    baseKey,
    { name: "HMAC", hash: "SHA-256", length: 256 },
    false,
    ["sign"]
  );

  // 4. Compute Write Signature Auth Token (HMAC signature of static tag)
  const signatureBuffer = await crypto.subtle.sign(
    "HMAC",
    verifyKey,
    encoder.encode("nid-write-auth")
  );
  const writeToken = arrayBufferToHex(signatureBuffer);

  return { address, encryptionKey, writeToken };
}

3. Server-Blind Worker Verification

The Cloudflare Worker accepts a `PUT /v1/:address` request containing the encrypted `blob` and the client's `writeToken`. The worker operates blindly:

// Inside Cloudflare Worker routing controller...
export async function handlePutRequest(request: Request, env: Env) {
  const { address } = request.params;
  const { blob, writeToken, verifyKeyHex, previousHash } = await request.json();

  const record = await env.NOTES_KV.get(address, { type: "json" });

  if (!record) {
    // First-time write: store the verifyKey and the encrypted blob
    await env.NOTES_KV.put(address, JSON.stringify({
      blob,
      verifyKey: verifyKeyHex,
      hash: await sha256(blob)
    }));
    return new Response("Created", { status: 201 });
  }

  // Subsequent writes: Import verifyKey and verify client's writeToken
  const verifyKey = await importHmacKey(record.verifyKey);
  const isValid = await verifyHmacSignature(verifyKey, writeToken, "nid-write-auth");

  if (!isValid) {
    return new Response("Unauthorized Signature", { status: 401 });
  }

  // Compare-and-Swap (CAS) check to prevent overwrite race conditions
  if (record.hash !== previousHash) {
    return new Response("Conflict: database updated elsewhere", { status: 409 });
  }

  // Update blob and update current hash
  await env.NOTES_KV.put(address, JSON.stringify({
    blob,
    verifyKey: record.verifyKey,
    hash: await sha256(blob)
  }));
  return new Response("OK", { status: 200 });
}

Platform Benefits

By offloading all cryptographic processes to the client and deploying an authentication-blind KV worker, users secure full ownership of their data. Even in the event of a database breach on Cloudflare, the attacker only acquires encrypted blobs and verification signatures, which cannot be decrypted without the local device key.

Scalability

The edge worker architecture operates without state, VMs, or persistent databases. With Cloudflare KV edge caching, read requests resolve globally near-instantly, scaling note synchronization at zero cost within Cloudflare's generous free tier limitations.

Other Selected Work Case Studies