Skip to content

Commit a56bbf4

Browse files
committed
core: add initial support for DEKs
1 parent cf63c2f commit a56bbf4

File tree

8 files changed

+596
-222
lines changed

8 files changed

+596
-222
lines changed
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
/*
2+
This file is part of the Notesnook project (https://notesnook.com/)
3+
4+
Copyright (C) 2023 Streetwriters (Private) Limited
5+
6+
This program is free software: you can redistribute it and/or modify
7+
it under the terms of the GNU General Public License as published by
8+
the Free Software Foundation, either version 3 of the License, or
9+
(at your option) any later version.
10+
11+
This program is distributed in the hope that it will be useful,
12+
but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
GNU General Public License for more details.
15+
16+
You should have received a copy of the GNU General Public License
17+
along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
*/
19+
20+
import { Cipher, SerializedKey, SerializedKeyPair } from "@notesnook/crypto";
21+
import Database from ".";
22+
import { isCipher } from "../utils";
23+
24+
const KEY_INFO = {
25+
inboxKeys: {
26+
type: "asymmetric"
27+
},
28+
attachmentsKey: {
29+
type: "symmetric"
30+
},
31+
monographPasswordsKey: {
32+
type: "symmetric"
33+
},
34+
dataEncryptionKey: {
35+
type: "symmetric"
36+
},
37+
legacyDataEncryptionKey: {
38+
type: "symmetric"
39+
}
40+
} as const;
41+
42+
export type KeyId = keyof typeof KEY_INFO;
43+
44+
type WrapKeyReturnType<T extends SerializedKeyPair | SerializedKey> =
45+
T extends SerializedKeyPair
46+
? { public: string; private: Cipher<"base64"> }
47+
: Cipher<"base64">;
48+
49+
type WrappedKey =
50+
| Cipher<"base64">
51+
| {
52+
public: string;
53+
private: Cipher<"base64">;
54+
};
55+
56+
type UnwrapKeyReturnType<T extends WrappedKey> = T extends {
57+
public: string;
58+
private: Cipher<"base64">;
59+
}
60+
? SerializedKeyPair
61+
: SerializedKey;
62+
63+
type KeyTypeFromId<TId extends KeyId> =
64+
(typeof KEY_INFO)[TId]["type"] extends "symmetric"
65+
? Cipher<"base64">
66+
: {
67+
public: string;
68+
private: Cipher<"base64">;
69+
};
70+
71+
export class KeyManager {
72+
private cache = new Map<string, KeyTypeFromId<KeyId>>();
73+
constructor(private readonly db: Database) {}
74+
75+
clearCache() {
76+
this.cache.clear();
77+
}
78+
79+
async get<TId extends KeyId>(
80+
id: TId,
81+
options: {
82+
useCache?: boolean;
83+
refetchUser?: boolean;
84+
} = { refetchUser: true, useCache: true }
85+
): Promise<KeyTypeFromId<TId> | undefined> {
86+
if (options.useCache && this.cache.has(id)) {
87+
return this.cache.get(id) as KeyTypeFromId<TId>;
88+
}
89+
let user = await this.db.user.getUser();
90+
if ((!user || !user[id]) && options.refetchUser) {
91+
user = await this.db.user.fetchUser();
92+
}
93+
if (!user) return;
94+
95+
this.cache.set(id, user[id] as KeyTypeFromId<KeyId>);
96+
return user[id] as KeyTypeFromId<TId>;
97+
}
98+
99+
async unwrapKey<T extends WrappedKey>(
100+
key: T,
101+
wrappingKey: SerializedKey
102+
): Promise<UnwrapKeyReturnType<T>> {
103+
if (isCipher(key))
104+
return JSON.parse(
105+
await this.db.storage().decrypt(wrappingKey, key)
106+
) as UnwrapKeyReturnType<T>;
107+
else {
108+
const privateKey = await this.db
109+
.storage()
110+
.decrypt(wrappingKey, key.private);
111+
return {
112+
publicKey: key.public,
113+
privateKey
114+
} as UnwrapKeyReturnType<T>;
115+
}
116+
}
117+
118+
async wrapKey<T extends SerializedKey | SerializedKeyPair>(
119+
key: T,
120+
wrappingKey: SerializedKey
121+
): Promise<WrapKeyReturnType<T>> {
122+
if (!("publicKey" in key)) {
123+
return (await this.db
124+
.storage()
125+
.encrypt(wrappingKey, JSON.stringify(key))) as WrapKeyReturnType<T>;
126+
} else {
127+
const encryptedPrivateKey = await this.db
128+
.storage()
129+
.encrypt(wrappingKey, (key as SerializedKeyPair).privateKey);
130+
return {
131+
public: (key as SerializedKeyPair).publicKey,
132+
private: encryptedPrivateKey
133+
} as WrapKeyReturnType<T>;
134+
}
135+
}
136+
137+
async rewrapKey<T extends WrappedKey>(
138+
key: T,
139+
oldWrappingKey: SerializedKey,
140+
newWrappingKey: SerializedKey
141+
) {
142+
const unwrappedKey = await this.unwrapKey(key, oldWrappingKey);
143+
return await this.wrapKey(unwrappedKey, newWrappingKey);
144+
}
145+
}

packages/core/src/api/sync/__tests__/collector.test.js

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,223 @@ test("unlinked relation should get included in collector", () =>
109109
expect(items[0].items[0].id).toBe("cd93df7a4c64fbd5f100361d629ac5b5");
110110
}));
111111

112+
test("collector should use latest key version for encryption", () =>
113+
databaseTest().then(async (db) => {
114+
await loginFakeUser(db);
115+
const collector = new Collector(db);
116+
117+
const noteId = await db.notes.add(TEST_NOTE);
118+
119+
const items = await iteratorToArray(collector.collect(100, false));
120+
121+
// Find the note item
122+
const noteItem = items.find((i) => i.type === "note");
123+
expect(noteItem).toBeDefined();
124+
expect(noteItem.items[0].keyVersion).toBeDefined();
125+
126+
// Should use the latest key version available
127+
const keys = await db.user.getDataEncryptionKeys();
128+
const latestKeyVersion = Math.max(...keys.map((k) => k.version));
129+
expect(noteItem.items[0].keyVersion).toBe(latestKeyVersion);
130+
}));
131+
132+
test("collector should assign keyVersion to all encrypted items", () =>
133+
databaseTest().then(async (db) => {
134+
await loginFakeUser(db);
135+
const collector = new Collector(db);
136+
137+
await db.notes.add(TEST_NOTE);
138+
await db.notes.add({ ...TEST_NOTE, title: "Note 2" });
139+
await db.notes.add({ ...TEST_NOTE, title: "Note 3" });
140+
141+
const items = await iteratorToArray(collector.collect(100, false));
142+
143+
// All items should have keyVersion set
144+
for (const chunk of items) {
145+
for (const item of chunk.items) {
146+
expect(item.keyVersion).toBeDefined();
147+
expect(typeof item.keyVersion).toBe("number");
148+
}
149+
}
150+
}));
151+
152+
test("sync roundtrip: items encrypted with keyVersion can be decrypted", () =>
153+
databaseTest().then(async (db) => {
154+
await loginFakeUser(db);
155+
const { Sync } = await import("../index.ts");
156+
const sync = new Sync(db);
157+
const collector = new Collector(db);
158+
159+
const noteId = await db.notes.add({
160+
...TEST_NOTE,
161+
title: "Sync Test Note"
162+
});
163+
const note = await db.notes.note(noteId);
164+
165+
const items = await iteratorToArray(collector.collect(100, false));
166+
const noteChunk = items.find((i) => i.type === "note");
167+
168+
expect(noteChunk).toBeDefined();
169+
expect(noteChunk.items[0].keyVersion).toBeDefined();
170+
171+
// Simulate receiving the same item back from server
172+
const keys = await db.user.getDataEncryptionKeys();
173+
await sync.processChunk(noteChunk, keys, { type: "fetch" });
174+
175+
// Verify the note is still intact
176+
const syncedNote = await db.notes.note(noteId);
177+
expect(syncedNote.title).toBe("Sync Test Note");
178+
expect(syncedNote.id).toBe(note.id);
179+
}));
180+
181+
test("sync should handle mixed keyVersion items in same chunk", () =>
182+
databaseTest().then(async (db) => {
183+
await loginFakeUser(db);
184+
const { Sync } = await import("../index.ts");
185+
const sync = new Sync(db);
186+
187+
const keys = await db.user.getDataEncryptionKeys();
188+
189+
// Create mock items with different key versions
190+
const note1 = JSON.stringify({
191+
id: "note1",
192+
type: "note",
193+
title: "Note 1",
194+
dateModified: Date.now()
195+
});
196+
const note2 = JSON.stringify({
197+
id: "note2",
198+
type: "note",
199+
title: "Note 2",
200+
dateModified: Date.now()
201+
});
202+
203+
const cipher1 = await db.storage().encrypt(keys[0].key, note1);
204+
const cipher2 =
205+
keys.length > 1
206+
? await db.storage().encrypt(keys[1].key, note2)
207+
: await db.storage().encrypt(keys[0].key, note2);
208+
209+
const chunk = {
210+
type: "note",
211+
count: 2,
212+
items: [
213+
{ ...cipher1, id: "note1", v: 5, keyVersion: keys[0].version },
214+
{
215+
...cipher2,
216+
id: "note2",
217+
v: 5,
218+
keyVersion: keys.length > 1 ? keys[1].version : keys[0].version
219+
}
220+
]
221+
};
222+
223+
// Process the chunk with mixed key versions
224+
await sync.processChunk(chunk, keys, { type: "fetch" });
225+
226+
// Verify both notes were decrypted correctly
227+
const savedNote1 = await db.notes.note("note1");
228+
const savedNote2 = await db.notes.note("note2");
229+
230+
expect(savedNote1).toBeDefined();
231+
expect(savedNote2).toBeDefined();
232+
expect(savedNote1.title).toBe("Note 1");
233+
expect(savedNote2.title).toBe("Note 2");
234+
}));
235+
236+
test("sync should maintain stable ordering across decryptMulti", () =>
237+
databaseTest().then(async (db) => {
238+
await loginFakeUser(db);
239+
const collector = new Collector(db);
240+
241+
// Create multiple notes with predictable order
242+
const noteIds = [];
243+
for (let i = 0; i < 5; i++) {
244+
const id = await db.notes.add({
245+
...TEST_NOTE,
246+
title: `Note ${i}`
247+
});
248+
noteIds.push(id);
249+
}
250+
251+
const items = await iteratorToArray(collector.collect(100, false));
252+
const noteChunk = items.find((i) => i.type === "note");
253+
254+
expect(noteChunk).toBeDefined();
255+
expect(noteChunk.items).toHaveLength(5);
256+
257+
// Verify all items have IDs
258+
const collectedIds = noteChunk.items.map((item) => item.id);
259+
expect(collectedIds).toHaveLength(5);
260+
261+
// All IDs should be present
262+
for (const id of noteIds) {
263+
expect(collectedIds).toContain(id);
264+
}
265+
266+
// Decrypt and verify ID mapping is preserved
267+
const keys = await db.user.getDataEncryptionKeys();
268+
const { Sync } = await import("../index.ts");
269+
const sync = new Sync(db);
270+
271+
await sync.processChunk(noteChunk, keys, { type: "fetch" });
272+
273+
// Verify each note can be retrieved with correct content
274+
for (let i = 0; i < 5; i++) {
275+
const note = await db.notes.note(noteIds[i]);
276+
expect(note).toBeDefined();
277+
expect(note.title).toBe(`Note ${i}`);
278+
}
279+
}));
280+
281+
test("sync should correctly select key based on keyVersion", () =>
282+
databaseTest().then(async (db) => {
283+
await loginFakeUser(db);
284+
const { Sync } = await import("../index.ts");
285+
const sync = new Sync(db);
286+
287+
const keys = await db.user.getDataEncryptionKeys();
288+
289+
// Create items encrypted with specific key versions
290+
const testCases = keys.map((keyInfo, idx) => ({
291+
id: `note${idx}`,
292+
title: `Note with keyVersion ${keyInfo.version}`,
293+
keyVersion: keyInfo.version,
294+
key: keyInfo.key
295+
}));
296+
297+
const chunks = [];
298+
for (const testCase of testCases) {
299+
const noteData = JSON.stringify({
300+
id: testCase.id,
301+
type: "note",
302+
title: testCase.title,
303+
dateModified: Date.now()
304+
});
305+
const cipher = await db.storage().encrypt(testCase.key, noteData);
306+
307+
chunks.push({
308+
type: "note",
309+
count: 1,
310+
items: [
311+
{ ...cipher, id: testCase.id, v: 5, keyVersion: testCase.keyVersion }
312+
]
313+
});
314+
}
315+
316+
// Process each chunk
317+
for (const chunk of chunks) {
318+
await sync.processChunk(chunk, keys, { type: "fetch" });
319+
}
320+
321+
// Verify each note was decrypted with the correct key
322+
for (const testCase of testCases) {
323+
const note = await db.notes.note(testCase.id);
324+
expect(note).toBeDefined();
325+
expect(note.title).toBe(testCase.title);
326+
}
327+
}));
328+
112329
async function iteratorToArray(iterator) {
113330
let items = [];
114331
for await (const item of iterator) {

0 commit comments

Comments
 (0)