[READ-ONLY] One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links 馃搮 calendar.xyehr.cn
nextjs
0

Configure Feed

Select the types of activity you want to include in your feed.

One-Calendar / lib / crypto.ts
3.4 kB 144 lines
1export type EncryptedPayload = { 2 ciphertext: string 3 iv: string 4} 5 6type KeyCacheMap = Map<string, Promise<CryptoKey>> 7const MAX_CACHE_ENTRIES = 128 8 9function b64(u: Uint8Array) { 10 return btoa(String.fromCharCode(...u)) 11} 12 13function ub64(s: string) { 14 return new Uint8Array( 15 atob(s) 16 .split('') 17 .map((c) => c.charCodeAt(0)), 18 ) 19} 20 21const passwordBaseKeyCache: KeyCacheMap = new Map() 22const derivedKeyCache: KeyCacheMap = new Map() 23 24function setWithLimit( 25 cache: KeyCacheMap, 26 key: string, 27 value: Promise<CryptoKey>, 28) { 29 if (!cache.has(key) && cache.size >= MAX_CACHE_ENTRIES) { 30 const oldestKey = cache.keys().next().value 31 if (oldestKey) cache.delete(oldestKey) 32 } 33 cache.set(key, value) 34} 35 36export function clearCryptoKeyCaches() { 37 passwordBaseKeyCache.clear() 38 derivedKeyCache.clear() 39} 40 41async function getPasswordBaseKey( 42 password: string, 43 cache = passwordBaseKeyCache, 44) { 45 const cached = cache.get(password) 46 if (cached) return cached 47 48 const baseKey = crypto.subtle.importKey( 49 'raw', 50 new TextEncoder().encode(password), 51 'PBKDF2', 52 false, 53 ['deriveKey'], 54 ) 55 setWithLimit(cache, password, baseKey) 56 return baseKey 57} 58 59export async function deriveCryptoKey( 60 password: string, 61 salt: Uint8Array, 62 cache = derivedKeyCache, 63) { 64 const saltArray = new Uint8Array(salt) 65 const cacheKey = `${password}:${b64(saltArray)}` 66 const cached = cache.get(cacheKey) 67 if (cached) return cached 68 69 const baseKey = await getPasswordBaseKey(password) 70 const derivedKey = crypto.subtle.deriveKey( 71 { 72 name: 'PBKDF2', 73 salt: saltArray, 74 iterations: 250000, 75 hash: 'SHA-256', 76 }, 77 baseKey, 78 { name: 'AES-GCM', length: 256 }, 79 false, 80 ['encrypt', 'decrypt'], 81 ) 82 83 setWithLimit(cache, cacheKey, derivedKey) 84 return derivedKey 85} 86 87function parseCiphertext(ciphertext: string): { salt: string; ct: string } { 88 const parsed = JSON.parse(ciphertext) 89 if (typeof parsed?.salt !== 'string' || typeof parsed?.ct !== 'string') { 90 throw new Error('Invalid encrypted payload format') 91 } 92 return parsed 93} 94 95export async function decryptWithDerivedKey( 96 password: string, 97 ciphertext: string, 98 iv: string, 99 cache?: KeyCacheMap, 100) { 101 const { salt, ct } = parseCiphertext(ciphertext) 102 const key = await deriveCryptoKey(password, ub64(salt), cache) 103 const pt = await crypto.subtle.decrypt( 104 { name: 'AES-GCM', iv: ub64(iv) }, 105 key, 106 ub64(ct), 107 ) 108 return new TextDecoder().decode(pt) 109} 110 111export async function encryptPayload( 112 password: string, 113 text: string, 114): Promise<EncryptedPayload> { 115 const salt = crypto.getRandomValues(new Uint8Array(16)) 116 const iv = crypto.getRandomValues(new Uint8Array(12)) 117 const key = await deriveCryptoKey(password, salt) 118 const ct = await crypto.subtle.encrypt( 119 { name: 'AES-GCM', iv }, 120 key, 121 new TextEncoder().encode(text), 122 ) 123 return { 124 ciphertext: JSON.stringify({ 125 v: 1, 126 salt: b64(salt), 127 ct: b64(new Uint8Array(ct)), 128 }), 129 iv: b64(iv), 130 } 131} 132 133export async function decryptPayload( 134 password: string, 135 ciphertext: string, 136 iv: string, 137) { 138 return decryptWithDerivedKey(password, ciphertext, iv) 139} 140 141export function isEncryptedPayload(value: unknown): value is EncryptedPayload { 142 if (!value || typeof value !== 'object') return false 143 return 'ciphertext' in value && 'iv' in value 144}