Skip to content

Encryption

diff·log uses client-side encryption to protect sensitive data. The server only stores encrypted blobs.

What's Encrypted

Data Encrypted Algorithm
API Keys Yes AES-256-GCM
Diffs Yes AES-256-GCM
Stars Yes AES-256-GCM
Profile metadata No Plaintext
Password Hashed SHA-256 (transport) + PBKDF2 (server)

Profile Metadata (Unencrypted)

The following fields are stored in plaintext on the server:

  • name - profile display name
  • languages - e.g., ["JavaScript", "Python"]
  • frameworks - e.g., ["React", "Django"]
  • tools - e.g., ["Docker", "Kubernetes"]
  • topics - e.g., ["AI/ML", "DevOps"]
  • depth - reading preference (quick, standard, deep)
  • custom_focus - optional custom focus text

This is intentional: profile preferences are low-sensitivity data (similar to a public GitHub profile) and need to be readable for the share page preview. Anyone with the profile ID can view these via GET /api/share/{id}.

Encryption Algorithm

All encryption uses AES-256-GCM via the Web Crypto API:

  • Key derivation: PBKDF2 with 100,000 iterations
  • Key length: 256 bits
  • IV: Random 12 bytes per encryption
  • Authentication: Built into GCM mode

Key Derivation

The encryption key is derived from the user's password:

async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
  const keyMaterial = await crypto.subtle.importKey(
    'raw',
    encoder.encode(password),
    'PBKDF2',
    false,
    ['deriveKey']
  );

  return crypto.subtle.deriveKey(
    {
      name: 'PBKDF2',
      salt,
      iterations: 100000,
      hash: 'SHA-256'
    },
    keyMaterial,
    { name: 'AES-GCM', length: 256 },
    false,
    ['encrypt', 'decrypt']
  );
}

Salt Reuse

The same salt is used for all content encryption within a profile. This is safe because each encryption uses a unique random IV.

Encrypting Data

API Keys & Provider Selections Encryption

When sharing a profile, API keys and provider selections are encrypted together as a single blob:

interface EncryptedKeysBlob {
  apiKeys: Record<string, string>;
  providerSelections?: {
    search?: string | null;
    curation?: string | null;
    synthesis?: string | null;
  };
}

async function encryptApiKeys(blob: EncryptedKeysBlob, password: string) {
  const salt = crypto.getRandomValues(new Uint8Array(16));
  const saltBase64 = uint8ToBase64(salt);

  // Encrypt the entire blob (keys + selections) as JSON
  const encrypted = await encryptData(blob, password, saltBase64);

  return {
    encrypted,
    salt: saltBase64
  };
}

Supported Keys: - anthropic - Anthropic API key - serper - Serper API key (web search) - perplexity - Perplexity API key (web search + synthesis) - deepseek - DeepSeek API key (curation + synthesis) - gemini - Google Gemini API key (curation + synthesis)

All keys and provider selections are encrypted together and stored as a single encrypted blob on the server. When importing a profile on another device, both keys and selections are restored. Legacy profiles that stored only a Record<string, string> of keys are handled transparently — on import, provider selections are inferred from the available keys.

Content Encryption

Diffs and stars are encrypted as JSON:

async function encryptData(data: any, password: string, salt: string) {
  const saltBytes = base64ToUint8(salt);
  const key = await deriveKey(password, saltBytes);
  const iv = crypto.getRandomValues(new Uint8Array(12));

  const encrypted = await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv },
    key,
    encoder.encode(JSON.stringify(data))
  );

  const combined = new Uint8Array(iv.length + encrypted.byteLength);
  combined.set(iv);
  combined.set(new Uint8Array(encrypted), iv.length);

  return base64(combined);
}

Decrypting Data

async function decryptData<T>(encrypted: string, password: string, salt: string): Promise<T> {
  const saltBytes = base64ToUint8(salt);
  const combined = base64ToUint8(encrypted);

  // Extract IV from first 12 bytes
  const iv = combined.slice(0, 12);
  const data = combined.slice(12);

  const key = await deriveKey(password, saltBytes);
  const decrypted = await crypto.subtle.decrypt(
    { name: 'AES-GCM', iv },
    key,
    data
  );

  return JSON.parse(new TextDecoder().decode(decrypted));
}

Password Hashing

Password hashing uses two layers for authentication (not encryption):

Transport Hash (Client-Side)

The client hashes the password with SHA-256 before sending it over the wire:

async function hashPasswordForTransport(password: string, salt?: string) {
  const saltValue = salt || base64(crypto.getRandomValues(new Uint8Array(16)));
  const data = encoder.encode(saltValue + password);
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  return saltValue + ':' + base64(new Uint8Array(hashBuffer));
}

Transport hash format: {clientSalt}:{base64(SHA-256)}

Example: zi7g35hMWZ4GjQlu32aFQg==:T1j5DDOgm4xOh+l9WBl8bpcHrkpJaTtdC+FGPWpymD4=

Server-Side Hash (Stored in DB)

The server applies PBKDF2 (100,000 iterations, SHA-256) to the transport hash before storing:

async function hashPasswordServer(transportHash: string): Promise<string> {
  const serverSalt = crypto.getRandomValues(new Uint8Array(16));
  const keyMaterial = await crypto.subtle.importKey(
    'raw', encoder.encode(transportHash), 'PBKDF2', false, ['deriveBits']
  );
  const derivedBits = await crypto.subtle.deriveBits(
    { name: 'PBKDF2', salt: serverSalt, iterations: 100000, hash: 'SHA-256' },
    keyMaterial, 256
  );
  return `v2:${base64(serverSalt)}:${base64(derivedBits)}`;
}

Stored hash format: v2:{base64(serverSalt)}:{base64(derivedKey)}

The v2: prefix distinguishes server-hashed passwords from legacy v1 hashes (plain transport hashes). Legacy hashes are automatically upgraded to v2 on successful authentication.

Why Two Layers?

  • Client-side SHA-256: Prevents the raw password from being sent to the server, even over TLS
  • Server-side PBKDF2: Ensures that a database breach doesn't expose passwords to offline cracking. Without this, the single-round SHA-256 transport hash could be cracked at billions of guesses/sec

Content Hash

For sync comparison, a hash is computed over all encrypted content:

async function computeHash(items: string[]): Promise<string> {
  const combined = [...items].sort().join('|');
  const data = encoder.encode(combined);
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('');
}

This hash is computed over encrypted data, so it can be stored on the server to detect changes without revealing content.

Security Model

graph LR
    subgraph Client
        P[Password] --> KD[Key Derivation]
        KD --> EK[Encryption Key]
        EK --> E[Encrypt]
        D[Data] --> E
        E --> ED[Encrypted Data]
    end

    subgraph Server
        ED2[Encrypted Data]
        H[Hash]
    end

    ED --> ED2
    ED --> H

    style P fill:#f96
    style EK fill:#9f9
    style ED fill:#99f
    style ED2 fill:#99f

Password Recovery

There is no password recovery. If you forget your password:

  • Your encrypted data cannot be decrypted
  • You must delete the profile and create a new one
  • Local unsynced data on other devices remains accessible

Implementation Notes

Browser Compatibility

Uses standard Web Crypto API, supported in all modern browsers:

  • Chrome 37+
  • Firefox 34+
  • Safari 11+
  • Edge 12+

Performance

  • Key derivation: ~100-200ms (intentionally slow for security)
  • Encryption/decryption: <1ms per item
  • Hash computation: <10ms for typical content

Error Handling

Decryption failures (wrong password) throw a DOMException:

try {
  await decryptData(encrypted, password, salt);
} catch (e) {
  if (e.name === 'OperationError') {
    // Wrong password or corrupted data
  }
}