[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
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}