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 |
Profile Metadata (Unencrypted)¶
The following fields are stored in plaintext on the server:
name- profile display namelanguages- 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 Key Encryption¶
When sharing a profile, the API key is encrypted:
async function encryptApiKey(apiKey: string, password: string) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await deriveKey(password, salt);
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoder.encode(apiKey)
);
// Prepend IV to encrypted data
const combined = new Uint8Array(iv.length + encrypted.byteLength);
combined.set(iv);
combined.set(new Uint8Array(encrypted), iv.length);
return {
encrypted: base64(combined),
salt: base64(salt)
};
}
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¶
Passwords are hashed for authentication (not encryption):
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));
}
Hash Format¶
The password hash is stored as: {salt}:{hash}
Example: zi7g35hMWZ4GjQlu32aFQg==:T1j5DDOgm4xOh+l9WBl8bpcHrkpJaTtdC+FGPWpymD4=
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: