Skip to content

Commit c42b387

Browse files
committed
v2.0.3 - Ability to make notes/note stacks E2EE
1 parent 04f9d2c commit c42b387

File tree

13 files changed

+888
-219
lines changed

13 files changed

+888
-219
lines changed

android/app/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ android {
2727
minSdkVersion rootProject.ext.minSdkVersion
2828
targetSdkVersion rootProject.ext.targetSdkVersion
2929
versionCode 1
30-
versionName "2.0.2"
30+
versionName "2.0.3"
3131
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
3232
aaptOptions {
3333
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

app/api/research/[id]/route.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ export async function PATCH(request: Request, context: RouteContext) {
7373
stack.notes = updates.notes ?? [];
7474
}
7575

76+
if (Object.prototype.hasOwnProperty.call(updates, "isEncryptedStack")) {
77+
stack.isEncryptedStack = updates.isEncryptedStack;
78+
}
79+
7680
await stack.save();
7781
const normalized = normalizeStack(stack.toObject());
7882
if (!normalized) {

components/MobileDashboard.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,6 +1047,7 @@ export default function MobileDashboard() {
10471047
description: updates.description,
10481048
tags: updates.tags,
10491049
notes: updates.notes,
1050+
isEncryptedStack: updates.isEncryptedStack,
10501051
};
10511052

10521053
const localOnly = isLocalOnly(existing);
@@ -1139,6 +1140,10 @@ export default function MobileDashboard() {
11391140
tags: note.tags ?? [],
11401141
createdAt: new Date().toISOString(),
11411142
updatedAt: new Date().toISOString(),
1143+
// E2E encryption fields
1144+
isEncrypted: note.isEncrypted,
1145+
encryptionSalt: note.encryptionSalt,
1146+
encryptionIV: note.encryptionIV,
11421147
};
11431148
const stack = stacks.find((s) => s.id === stackId);
11441149
if (!stack) return;

components/dashboard/NotebookView.tsx

Lines changed: 434 additions & 90 deletions
Large diffs are not rendered by default.

eslint.config.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const config = [
1414
},
1515
// Ignore build output and dependencies
1616
{
17-
ignores: ['.next/**', 'node_modules/**'],
17+
ignores: ['.next/**', 'node_modules/**', 'mongo/**', 'minio/**', 'wsca/**'],
1818
},
1919
];
2020

ios/App/App.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -405,7 +405,7 @@
405405
"@executable_path/Frameworks",
406406
);
407407
LIBRARY_SEARCH_PATHS = "$(inherited)";
408-
MARKETING_VERSION = 2.0.2;
408+
MARKETING_VERSION = 2.0.3;
409409
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
410410
PRODUCT_BUNDLE_IDENTIFIER = xyz.moltly.app;
411411
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -433,7 +433,7 @@
433433
"@executable_path/Frameworks",
434434
);
435435
LIBRARY_SEARCH_PATHS = "$(inherited)";
436-
MARKETING_VERSION = 2.0.2;
436+
MARKETING_VERSION = 2.0.3;
437437
PRODUCT_BUNDLE_IDENTIFIER = xyz.moltly.app;
438438
PRODUCT_NAME = "$(TARGET_NAME)";
439439
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

lib/changelog.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ export type ChangelogEntry = {
55
};
66

77
const entries: ChangelogEntry[] = [
8+
{
9+
version: "2.0.3",
10+
date: "2025-12-10",
11+
highlights: [
12+
"Add the ability to make Notes/Note Stacks End-To-End Encrypted."
13+
]
14+
},
815
{
916
version: "2.0.2",
1017
date: "2025-12-10",

lib/note-crypto.ts

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
/**
2+
* End-to-end encryption utilities for notebook notes.
3+
* Uses Web Crypto API with AES-256-GCM for encryption.
4+
* Encryption/decryption happens entirely client-side.
5+
*/
6+
7+
// Storage key for cached password hash in sessionStorage
8+
const PASSWORD_CACHE_KEY = "moltly_e2e_pw_hash";
9+
10+
/**
11+
* Derive an AES-256 key from a password using PBKDF2.
12+
*/
13+
export async function deriveKeyFromPassword(
14+
password: string,
15+
salt: Uint8Array
16+
): Promise<CryptoKey> {
17+
const encoder = new TextEncoder();
18+
const passwordBuffer = encoder.encode(password);
19+
20+
// Import the password as a key for PBKDF2
21+
const baseKey = await crypto.subtle.importKey(
22+
"raw",
23+
passwordBuffer,
24+
"PBKDF2",
25+
false,
26+
["deriveKey"]
27+
);
28+
29+
// Derive the AES key
30+
return crypto.subtle.deriveKey(
31+
{
32+
name: "PBKDF2",
33+
salt: salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer,
34+
iterations: 100000,
35+
hash: "SHA-256",
36+
},
37+
baseKey,
38+
{ name: "AES-GCM", length: 256 },
39+
false,
40+
["encrypt", "decrypt"]
41+
);
42+
}
43+
44+
/**
45+
* Generate a random salt for key derivation.
46+
*/
47+
export function generateSalt(): Uint8Array {
48+
return crypto.getRandomValues(new Uint8Array(16));
49+
}
50+
51+
/**
52+
* Generate a random initialization vector for AES-GCM.
53+
*/
54+
export function generateIV(): Uint8Array {
55+
return crypto.getRandomValues(new Uint8Array(12));
56+
}
57+
58+
/**
59+
* Convert Uint8Array to base64 string.
60+
*/
61+
export function uint8ArrayToBase64(array: Uint8Array): string {
62+
let binary = "";
63+
for (let i = 0; i < array.length; i++) {
64+
binary += String.fromCharCode(array[i]);
65+
}
66+
return btoa(binary);
67+
}
68+
69+
/**
70+
* Convert base64 string to Uint8Array.
71+
*/
72+
export function base64ToUint8Array(base64: string): Uint8Array {
73+
const binary = atob(base64);
74+
const array = new Uint8Array(binary.length);
75+
for (let i = 0; i < binary.length; i++) {
76+
array[i] = binary.charCodeAt(i);
77+
}
78+
return array;
79+
}
80+
81+
/**
82+
* Encrypt text content using AES-256-GCM.
83+
* Returns encrypted data with salt and IV for storage.
84+
*/
85+
export async function encryptContent(
86+
plaintext: string,
87+
password: string
88+
): Promise<{ ciphertext: string; salt: string; iv: string }> {
89+
const encoder = new TextEncoder();
90+
const salt = generateSalt();
91+
const iv = generateIV();
92+
const key = await deriveKeyFromPassword(password, salt);
93+
94+
const encrypted = await crypto.subtle.encrypt(
95+
{ name: "AES-GCM", iv: iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength) as ArrayBuffer },
96+
key,
97+
encoder.encode(plaintext)
98+
);
99+
100+
return {
101+
ciphertext: uint8ArrayToBase64(new Uint8Array(encrypted)),
102+
salt: uint8ArrayToBase64(salt),
103+
iv: uint8ArrayToBase64(iv),
104+
};
105+
}
106+
107+
/**
108+
* Decrypt content using AES-256-GCM.
109+
* Requires the same salt and IV used during encryption.
110+
*/
111+
export async function decryptContent(
112+
ciphertext: string,
113+
password: string,
114+
salt: string,
115+
iv: string
116+
): Promise<string> {
117+
const decoder = new TextDecoder();
118+
const saltArray = base64ToUint8Array(salt);
119+
const ivArray = base64ToUint8Array(iv);
120+
const ciphertextArray = base64ToUint8Array(ciphertext);
121+
122+
const key = await deriveKeyFromPassword(password, saltArray);
123+
124+
const decrypted = await crypto.subtle.decrypt(
125+
{ name: "AES-GCM", iv: ivArray.buffer.slice(ivArray.byteOffset, ivArray.byteOffset + ivArray.byteLength) as ArrayBuffer },
126+
key,
127+
ciphertextArray.buffer.slice(ciphertextArray.byteOffset, ciphertextArray.byteOffset + ciphertextArray.byteLength) as ArrayBuffer
128+
);
129+
130+
return decoder.decode(decrypted);
131+
}
132+
133+
/**
134+
* Encrypt a note's sensitive fields (title and content).
135+
* Returns the encrypted data ready for storage.
136+
*/
137+
export async function encryptNote(
138+
title: string,
139+
content: string,
140+
password: string
141+
): Promise<{
142+
encryptedTitle: string;
143+
encryptedContent: string;
144+
salt: string;
145+
iv: string;
146+
}> {
147+
// Use the same salt and IV for both fields to simplify
148+
const salt = generateSalt();
149+
const iv = generateIV();
150+
const key = await deriveKeyFromPassword(password, salt);
151+
const encoder = new TextEncoder();
152+
153+
// Encrypt title
154+
const encryptedTitleBuffer = await crypto.subtle.encrypt(
155+
{ name: "AES-GCM", iv: iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength) as ArrayBuffer },
156+
key,
157+
encoder.encode(title)
158+
);
159+
160+
// Encrypt content with a different IV for security
161+
const contentIV = generateIV();
162+
const encryptedContentBuffer = await crypto.subtle.encrypt(
163+
{ name: "AES-GCM", iv: contentIV.buffer.slice(contentIV.byteOffset, contentIV.byteOffset + contentIV.byteLength) as ArrayBuffer },
164+
key,
165+
encoder.encode(content)
166+
);
167+
168+
return {
169+
encryptedTitle: uint8ArrayToBase64(new Uint8Array(encryptedTitleBuffer)),
170+
encryptedContent: `${uint8ArrayToBase64(contentIV)}:${uint8ArrayToBase64(new Uint8Array(encryptedContentBuffer))}`,
171+
salt: uint8ArrayToBase64(salt),
172+
iv: uint8ArrayToBase64(iv),
173+
};
174+
}
175+
176+
/**
177+
* Decrypt a note's sensitive fields (title and content).
178+
*/
179+
export async function decryptNote(
180+
encryptedTitle: string,
181+
encryptedContent: string,
182+
password: string,
183+
salt: string,
184+
iv: string
185+
): Promise<{ title: string; content: string }> {
186+
const saltArray = base64ToUint8Array(salt);
187+
const ivArray = base64ToUint8Array(iv);
188+
const key = await deriveKeyFromPassword(password, saltArray);
189+
const decoder = new TextDecoder();
190+
191+
// Decrypt title
192+
const encryptedTitleArray = base64ToUint8Array(encryptedTitle);
193+
const titleBuffer = await crypto.subtle.decrypt(
194+
{ name: "AES-GCM", iv: ivArray.buffer.slice(ivArray.byteOffset, ivArray.byteOffset + ivArray.byteLength) as ArrayBuffer },
195+
key,
196+
encryptedTitleArray.buffer.slice(encryptedTitleArray.byteOffset, encryptedTitleArray.byteOffset + encryptedTitleArray.byteLength) as ArrayBuffer
197+
);
198+
199+
// Decrypt content (has its own IV prepended)
200+
const [contentIVBase64, contentCiphertext] = encryptedContent.split(":");
201+
const contentIV = base64ToUint8Array(contentIVBase64);
202+
const contentCiphertextArray = base64ToUint8Array(contentCiphertext);
203+
const contentBuffer = await crypto.subtle.decrypt(
204+
{ name: "AES-GCM", iv: contentIV.buffer.slice(contentIV.byteOffset, contentIV.byteOffset + contentIV.byteLength) as ArrayBuffer },
205+
key,
206+
contentCiphertextArray.buffer.slice(contentCiphertextArray.byteOffset, contentCiphertextArray.byteOffset + contentCiphertextArray.byteLength) as ArrayBuffer
207+
);
208+
209+
return {
210+
title: decoder.decode(titleBuffer),
211+
content: decoder.decode(contentBuffer),
212+
};
213+
}
214+
215+
/**
216+
* Hash password for session caching (not for security, just for quick comparison).
217+
*/
218+
export async function hashPasswordForCache(password: string): Promise<string> {
219+
const encoder = new TextEncoder();
220+
const data = encoder.encode(password);
221+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
222+
return uint8ArrayToBase64(new Uint8Array(hashBuffer));
223+
}
224+
225+
/**
226+
* Cache the password hash in sessionStorage.
227+
*/
228+
export function cachePasswordHash(hash: string): void {
229+
if (typeof sessionStorage !== "undefined") {
230+
sessionStorage.setItem(PASSWORD_CACHE_KEY, hash);
231+
}
232+
}
233+
234+
/**
235+
* Get cached password hash from sessionStorage.
236+
*/
237+
export function getCachedPasswordHash(): string | null {
238+
if (typeof sessionStorage !== "undefined") {
239+
return sessionStorage.getItem(PASSWORD_CACHE_KEY);
240+
}
241+
return null;
242+
}
243+
244+
/**
245+
* Clear cached password hash.
246+
*/
247+
export function clearCachedPasswordHash(): void {
248+
if (typeof sessionStorage !== "undefined") {
249+
sessionStorage.removeItem(PASSWORD_CACHE_KEY);
250+
}
251+
}
252+
253+
/**
254+
* Check if we have a cached password that matches.
255+
*/
256+
export async function verifyCachedPassword(password: string): Promise<boolean> {
257+
const cached = getCachedPasswordHash();
258+
if (!cached) return false;
259+
const hash = await hashPasswordForCache(password);
260+
return hash === cached;
261+
}

0 commit comments

Comments
 (0)