Server-Blind Sync Layer
How we built private, end-to-end encrypted note syncing using browser WebCrypto APIs and Cloudflare Workers.
The Architectural Problem
Most sync setups store user passwords and readable data directly on a central server. If the database gets leaked or breached, all user notes are exposed.
For Note It Down, we wanted a zero-knowledge setup. Your encryption keys never leave your browser. The Cloudflare KV database only ever sees encrypted blobs, and the server validates write permissions without knowing your raw token.
Sync Sequence Flow
Step-by-step key derivation, decryption, merging, and compare-and-swap write validation
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:
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.
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.
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.