Files
silent-send/src/lib/crypto.js
T
Claude d1bc43d02d fix: cross-browser sync decryption + WebAuthn on extension pages
Sync code import between browsers was failing with "Wrong key or
corrupted data" because each browser derives a different AES key
from the same password (different salt). The decrypt function was
using the local device's salt instead of the source device's salt
embedded in the sync envelope.

Fixed _decryptFromSync to always use the sync envelope's salt for
decryption. If the cached key's salt doesn't match, forces re-auth
so the password is re-entered and a new key is derived with the
correct salt.

Also fixed WebAuthn "device can't be used" error on extension pages.
chrome-extension:// and moz-extension:// origins are not valid for
WebAuthn. isWebAuthnAvailable() now returns false on extension pages.

https://claude.ai/code/session_01SWSwDfMVij53bCTNSCLMwn
2026-03-27 04:38:25 +00:00

520 lines
16 KiB
JavaScript

/**
* Silent Send - Crypto Module
*
* AES-256-GCM encryption with PBKDF2 key derivation.
* TOTP (RFC 6238) for optional second factor.
* Key caching in IndexedDB with configurable TTL.
* WebAuthn biometric unlock as low-friction re-auth.
*/
const SALT_LENGTH = 16;
const IV_LENGTH = 12;
const ITERATIONS = 100000;
// TOTP defaults (RFC 6238)
const TOTP_DIGITS = 6;
const TOTP_PERIOD = 30;
const TOTP_WINDOW = 1; // accept ±1 period for clock skew
async function deriveKey(password, salt) {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(password),
'PBKDF2',
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt,
iterations: ITERATIONS,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}
const SilentSendCrypto = {
/**
* Encrypt data with a password.
* Returns a base64 string containing salt + iv + ciphertext.
*/
async encrypt(data, password) {
const encoder = new TextEncoder();
const salt = crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const key = await deriveKey(password, salt);
const plaintext = encoder.encode(JSON.stringify(data));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
plaintext
);
// Combine: salt (16) + iv (12) + ciphertext
const combined = new Uint8Array(salt.length + iv.length + ciphertext.byteLength);
combined.set(salt, 0);
combined.set(iv, salt.length);
combined.set(new Uint8Array(ciphertext), salt.length + iv.length);
// Base64 encode
return btoa(String.fromCharCode(...combined));
},
/**
* Decrypt data with a password.
* Takes the base64 string from encrypt().
*/
async decrypt(encryptedBase64, password) {
const combined = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0));
const salt = combined.slice(0, SALT_LENGTH);
const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
const ciphertext = combined.slice(SALT_LENGTH + IV_LENGTH);
const key = await deriveKey(password, salt);
try {
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
);
const decoder = new TextDecoder();
return JSON.parse(decoder.decode(plaintext));
} catch (e) {
throw new Error('Wrong password or corrupted data');
}
},
/**
* Encrypt data with a CryptoKey directly (used with cached keys).
*/
async encryptWithKey(data, key) {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const plaintext = encoder.encode(JSON.stringify(data));
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
plaintext
);
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
combined.set(iv, 0);
combined.set(new Uint8Array(ciphertext), iv.length);
return btoa(String.fromCharCode(...combined));
},
/**
* Decrypt data with a CryptoKey directly (used with cached keys).
*/
async decryptWithKey(encryptedBase64, key) {
const combined = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0));
const iv = combined.slice(0, IV_LENGTH);
const ciphertext = combined.slice(IV_LENGTH);
try {
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
);
const decoder = new TextDecoder();
return JSON.parse(decoder.decode(plaintext));
} catch (e) {
throw new Error('Wrong key or corrupted data');
}
},
// ----------------------------------------------------------------
// Derive + cache: password → CryptoKey, stored with a salt
// Returns { key, salt } where salt should be persisted alongside
// encrypted data so the same password reproduces the same key.
// ----------------------------------------------------------------
async deriveAndReturnKey(password, existingSalt) {
const salt = existingSalt
? (typeof existingSalt === 'string'
? Uint8Array.from(atob(existingSalt), c => c.charCodeAt(0))
: existingSalt)
: crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const key = await deriveKey(password, salt);
const saltB64 = typeof existingSalt === 'string'
? existingSalt
: btoa(String.fromCharCode(...salt));
return { key, salt: saltB64 };
},
// ----------------------------------------------------------------
// TOTP — Time-based One-Time Password (RFC 6238)
// ----------------------------------------------------------------
/**
* Generate a random TOTP secret (base32-encoded, 20 bytes).
*/
generateTOTPSecret() {
const bytes = crypto.getRandomValues(new Uint8Array(20));
return base32Encode(bytes);
},
/**
* Generate the current TOTP code from a base32 secret.
*/
async generateTOTP(secret) {
const counter = Math.floor(Date.now() / 1000 / TOTP_PERIOD);
return this._hotpCode(secret, counter);
},
/**
* Validate a TOTP code against the secret.
* Accepts codes within ±TOTP_WINDOW periods for clock skew.
*/
async validateTOTP(secret, code) {
const counter = Math.floor(Date.now() / 1000 / TOTP_PERIOD);
for (let i = -TOTP_WINDOW; i <= TOTP_WINDOW; i++) {
const expected = await this._hotpCode(secret, counter + i);
if (expected === code.toString().padStart(TOTP_DIGITS, '0')) {
return true;
}
}
return false;
},
/**
* Build an otpauth:// URI for QR code generators.
*/
totpURI(secret, accountName = 'SilentSend', issuer = 'SilentSend') {
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(accountName)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}&digits=${TOTP_DIGITS}&period=${TOTP_PERIOD}`;
},
async _hotpCode(secret, counter) {
const keyBytes = base32Decode(secret);
const counterBuf = new ArrayBuffer(8);
const view = new DataView(counterBuf);
view.setBigUint64(0, BigInt(counter));
const key = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']
);
const sig = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBuf));
// Dynamic truncation (RFC 4226 §5.4)
const offset = sig[sig.length - 1] & 0x0f;
const code = (
((sig[offset] & 0x7f) << 24) |
((sig[offset + 1] & 0xff) << 16) |
((sig[offset + 2] & 0xff) << 8) |
(sig[offset + 3] & 0xff)
) % (10 ** TOTP_DIGITS);
return code.toString().padStart(TOTP_DIGITS, '0');
},
// ----------------------------------------------------------------
// Key Cache — persist CryptoKey in IndexedDB with TTL
//
// The CryptoKey is non-exportable (extractable: false from PBKDF2),
// so it can only be used via SubtleCrypto, never read as raw bytes.
// ----------------------------------------------------------------
_cacheDB: null,
async _openCacheDB() {
if (this._cacheDB) return this._cacheDB;
return new Promise((resolve, reject) => {
const req = indexedDB.open('ss_key_cache', 1);
req.onupgradeneeded = () => {
req.result.createObjectStore('keys');
};
req.onsuccess = () => { this._cacheDB = req.result; resolve(req.result); };
req.onerror = () => reject(req.error);
});
},
/**
* Cache a CryptoKey persistently.
* The key stays in IndexedDB indefinitely — it's only cleared if
* the user explicitly disables encryption or clears browser data.
* The TTL controls when re-verification (via WebAuthn) is required,
* NOT when the key is deleted.
*
* @param {CryptoKey} key
* @param {string} salt - base64-encoded salt used to derive this key
* @param {number} ttlDays - re-verify interval: 0 = each session, -1 = never
*/
async cacheKey(key, salt, ttlDays = 90) {
const db = await this._openCacheDB();
const reverifyAt = ttlDays === -1
? -1 // never re-verify
: ttlDays === 0
? 0 // re-verify each session
: Date.now() + ttlDays * 86400000;
return new Promise((resolve, reject) => {
const tx = db.transaction('keys', 'readwrite');
tx.objectStore('keys').put({ key, salt, reverifyAt, cachedAt: Date.now() }, 'syncKey');
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
},
/**
* Retrieve cached key. The key is always returned if it exists —
* it never expires or self-deletes. Use needsReverification() to
* check if the user should re-verify via WebAuthn.
*
* Returns { key: CryptoKey, salt: string } or null.
*/
async getCachedKey() {
try {
const db = await this._openCacheDB();
return new Promise((resolve) => {
const tx = db.transaction('keys', 'readonly');
const req = tx.objectStore('keys').get('syncKey');
req.onsuccess = () => {
const entry = req.result;
if (!entry?.key) return resolve(null);
resolve({ key: entry.key, salt: entry.salt });
};
req.onerror = () => resolve(null);
});
} catch {
return null;
}
},
/**
* Check if the cached key needs re-verification (TTL expired).
* This does NOT delete the key — it just signals that the user
* should re-verify via WebAuthn/biometric before using it.
*/
async needsReverification() {
try {
const db = await this._openCacheDB();
return new Promise((resolve) => {
const tx = db.transaction('keys', 'readonly');
const req = tx.objectStore('keys').get('syncKey');
req.onsuccess = () => {
const entry = req.result;
if (!entry) return resolve(false); // no key = nothing to re-verify
if (entry.reverifyAt === -1) return resolve(false); // never
if (entry.reverifyAt === 0) return resolve(true); // each session
resolve(Date.now() >= entry.reverifyAt);
};
req.onerror = () => resolve(false);
});
} catch {
return false;
}
},
/**
* Mark the cached key as freshly verified (resets the TTL timer).
*/
async markVerified(ttlDays = 90) {
try {
const db = await this._openCacheDB();
const entry = await new Promise((resolve) => {
const tx = db.transaction('keys', 'readonly');
const req = tx.objectStore('keys').get('syncKey');
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
if (!entry) return;
entry.reverifyAt = ttlDays === -1
? -1
: ttlDays === 0
? 0
: Date.now() + ttlDays * 86400000;
await new Promise((resolve, reject) => {
const tx = db.transaction('keys', 'readwrite');
tx.objectStore('keys').put(entry, 'syncKey');
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
} catch { /* non-fatal */ }
},
async clearCachedKey() {
try {
const db = await this._openCacheDB();
return new Promise((resolve) => {
const tx = db.transaction('keys', 'readwrite');
tx.objectStore('keys').delete('syncKey');
tx.oncomplete = resolve;
tx.onerror = resolve;
});
} catch { /* ignore */ }
},
// ----------------------------------------------------------------
// WebAuthn — biometric/PIN as PRIMARY re-authentication
//
// On first setup (per device), the user enters their password once
// to derive the encryption key. A WebAuthn credential is registered
// on that device. From then on, ALL re-verification uses biometrics
// — the password is never needed again on that device unless
// IndexedDB is cleared. The CryptoKey persists indefinitely in
// IndexedDB; WebAuthn just gates access when TTL expires.
// ----------------------------------------------------------------
/**
* Check if WebAuthn is available in this browser.
*/
isWebAuthnAvailable() {
// WebAuthn doesn't work on extension pages (chrome-extension:// origin)
if (typeof location !== 'undefined' && location.protocol === 'chrome-extension:') return false;
if (typeof location !== 'undefined' && location.protocol === 'moz-extension:') return false;
return !!(window.PublicKeyCredential && navigator.credentials);
},
/**
* Register a WebAuthn credential for this extension.
* Returns the credential ID (base64) to store in settings.
*/
async webAuthnRegister() {
const challenge = crypto.getRandomValues(new Uint8Array(32));
const userId = crypto.getRandomValues(new Uint8Array(16));
const credential = await navigator.credentials.create({
publicKey: {
rp: { name: 'Silent Send' },
user: {
id: userId,
name: 'silentsend-user',
displayName: 'Silent Send User',
},
challenge,
pubKeyCredParams: [
{ type: 'public-key', alg: -7 }, // ES256
{ type: 'public-key', alg: -257 }, // RS256
],
authenticatorSelection: {
authenticatorAttachment: 'platform', // built-in biometric/PIN
userVerification: 'required',
residentKey: 'discouraged',
},
timeout: 60000,
},
});
const credId = btoa(String.fromCharCode(...new Uint8Array(credential.rawId)));
// Store credential info in IndexedDB
const db = await this._openCacheDB();
await new Promise((resolve, reject) => {
const tx = db.transaction('keys', 'readwrite');
tx.objectStore('keys').put({ credId, createdAt: Date.now() }, 'webauthnCred');
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
return credId;
},
/**
* Authenticate with WebAuthn (biometric/PIN prompt).
* Returns true if verification succeeds.
*/
async webAuthnAuthenticate() {
try {
const db = await this._openCacheDB();
const stored = await new Promise((resolve) => {
const tx = db.transaction('keys', 'readonly');
const req = tx.objectStore('keys').get('webauthnCred');
req.onsuccess = () => resolve(req.result);
req.onerror = () => resolve(null);
});
if (!stored?.credId) return false;
const credIdBytes = Uint8Array.from(atob(stored.credId), c => c.charCodeAt(0));
const challenge = crypto.getRandomValues(new Uint8Array(32));
const assertion = await navigator.credentials.get({
publicKey: {
challenge,
allowCredentials: [{ type: 'public-key', id: credIdBytes }],
userVerification: 'required',
timeout: 60000,
},
});
// If we get here without throwing, the platform verified the user
return !!assertion;
} catch {
return false;
}
},
/**
* Check if WebAuthn credential is registered.
*/
async hasWebAuthnCredential() {
try {
const db = await this._openCacheDB();
return new Promise((resolve) => {
const tx = db.transaction('keys', 'readonly');
const req = tx.objectStore('keys').get('webauthnCred');
req.onsuccess = () => resolve(!!req.result?.credId);
req.onerror = () => resolve(false);
});
} catch {
return false;
}
},
async clearWebAuthnCredential() {
try {
const db = await this._openCacheDB();
return new Promise((resolve) => {
const tx = db.transaction('keys', 'readwrite');
tx.objectStore('keys').delete('webauthnCred');
tx.oncomplete = resolve;
tx.onerror = resolve;
});
} catch { /* ignore */ }
},
};
// ----------------------------------------------------------------
// Base32 encode/decode helpers (RFC 4648, no padding)
// ----------------------------------------------------------------
const B32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function base32Encode(bytes) {
let bits = '';
for (const b of bytes) bits += b.toString(2).padStart(8, '0');
let out = '';
for (let i = 0; i < bits.length; i += 5) {
out += B32[parseInt(bits.slice(i, i + 5).padEnd(5, '0'), 2)];
}
return out;
}
function base32Decode(str) {
let bits = '';
for (const c of str.toUpperCase().replace(/[^A-Z2-7]/g, '')) {
bits += B32.indexOf(c).toString(2).padStart(5, '0');
}
const bytes = [];
for (let i = 0; i + 8 <= bits.length; i += 8) {
bytes.push(parseInt(bits.slice(i, i + 8), 2));
}
return new Uint8Array(bytes);
}
export default SilentSendCrypto;